├── .codecov.yml ├── .cruft.json ├── .github └── workflows │ ├── actions │ └── python-poetry │ │ └── action.yml │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── examples ├── create_send_email.py ├── debug_log.py ├── echo.py ├── event_source.py ├── identity_get.py ├── mailbox.py └── recent_threads.py ├── img └── jmapc.png ├── jmapc ├── __init__.py ├── __version__.py ├── api.py ├── auth.py ├── client.py ├── constants.py ├── errors.py ├── fastmail │ ├── __init__.py │ ├── maskedemail_methods.py │ └── maskedemail_models.py ├── logging.py ├── methods │ ├── __init__.py │ ├── base.py │ ├── core.py │ ├── custom.py │ ├── email.py │ ├── email_submission.py │ ├── identity.py │ ├── mailbox.py │ ├── search_snippet.py │ └── thread.py ├── models │ ├── __init__.py │ ├── email.py │ ├── email_submission.py │ ├── event.py │ ├── identity.py │ ├── mailbox.py │ ├── models.py │ ├── search_snippet.py │ └── thread.py ├── py.typed ├── ref.py ├── serializer.py └── session.py ├── poetry.lock ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── data.py ├── methods │ ├── __init__.py │ ├── test_base.py │ ├── test_core.py │ ├── test_custom.py │ ├── test_email.py │ ├── test_email_submission.py │ ├── test_errors.py │ ├── test_fastmail_maskedemail.py │ ├── test_identity.py │ ├── test_mailbox.py │ ├── test_searchsnippet.py │ └── test_thread.py ├── test_client.py ├── test_events.py ├── test_module.py ├── test_ref.py ├── test_serializer.py └── utils.py └── types └── sseclient.pyi /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 0.2% 6 | -------------------------------------------------------------------------------- /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "https://github.com/smkent/cookie-python", 3 | "commit": "7025e55adab820dd8e63d8be0e0ec0cabad0d830", 4 | "context": { 5 | "cookiecutter": { 6 | "project_name": "jmapc", 7 | "project_slug": "jmapc", 8 | "project_description": "JMAP client library for Python", 9 | "project_license": "GPL-3.0-or-later", 10 | "enable_coverage": "yes", 11 | "enable_pypi_publish": "yes", 12 | "enable_container_publish": "no", 13 | "author_name": "Stephen Kent", 14 | "author_email": "smkent@smkent.net", 15 | "github_user": "smkent", 16 | "_template": "https://github.com/smkent/cookie-python" 17 | } 18 | }, 19 | "directory": null, 20 | "checkout": null 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/actions/python-poetry/action.yml: -------------------------------------------------------------------------------- 1 | name: Set up Python project with Poetry 2 | 3 | inputs: 4 | python_version: 5 | description: "Python version to install" 6 | required: true 7 | poetry_version: 8 | description: "Poetry version to install" 9 | required: true 10 | 11 | runs: 12 | using: composite 13 | steps: 14 | - name: ✨ Install Poetry 15 | shell: bash 16 | run: | 17 | python3 -m pip install \ 18 | "poetry==${{ inputs.poetry_version }}.*" \ 19 | "poetry-dynamic-versioning" 20 | python3 -m pip install --upgrade requests 21 | 22 | - name: 🐍 Set up Python 23 | uses: actions/setup-python@v5 24 | id: setuppy 25 | with: 26 | python-version: ${{ inputs.python_version }} 27 | cache: poetry 28 | 29 | - name: 🛠️ Install project and dependencies 30 | shell: bash 31 | env: 32 | # https://github.com/python-poetry/poetry/issues/1917 33 | # https://github.com/actions/runner-images/issues/6185 34 | PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring" 35 | run: | 36 | poetry check --lock 37 | poetry sync 38 | PROJECT_VERSION=$(poetry version -s) 39 | [ "${PROJECT_VERSION}" != "0.0.0" ] \ 40 | || { echo "Versioning broken"; exit 1; } 41 | echo "PROJECT_VERSION=${PROJECT_VERSION}" >> $GITHUB_ENV 42 | 43 | - name: 🪝 Cache pre-commit hooks 44 | uses: actions/cache@v4 45 | with: 46 | path: ~/.cache/pre-commit 47 | key: "pre-commit-${{ runner.os }}-python\ 48 | -${{ steps.setuppy.outputs.python-version }}\ 49 | -${{ hashFiles('.pre-commit-config.yaml') }}" 50 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | env: 5 | ENABLE_PYPI_PUBLISH: true 6 | ENABLE_TEST_PYPI_PUBLISH: false 7 | RELEASE_PYTHON_VERSION: "3.12" 8 | RELEASE_POETRY_VERSION: "2.0" 9 | 10 | on: 11 | push: 12 | tags: 13 | - '*' 14 | 15 | jobs: 16 | Publish: 17 | name: Publish package for ${{ github.ref_name }} 18 | 19 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 20 | runs-on: ubuntu-latest 21 | environment: 22 | name: pypi 23 | url: https://pypi.org/p/jmapc 24 | permissions: 25 | id-token: write 26 | 27 | steps: 28 | - name: 💾 Check out repository 29 | uses: actions/checkout@v4 30 | 31 | - name: 🐍 Set up Python project with Poetry 32 | uses: ./.github/workflows/actions/python-poetry 33 | with: 34 | python_version: ${{ env.RELEASE_PYTHON_VERSION }} 35 | poetry_version: ${{ env.RELEASE_POETRY_VERSION }} 36 | 37 | - name: 🔥 Test 38 | run: poetry run poe test 39 | 40 | - name: 🚒 Create test summary 41 | uses: test-summary/action@v1 42 | if: success() || failure() 43 | with: 44 | paths: ./.pytest_results.xml 45 | 46 | - name: 📦 Build package 47 | if: | 48 | env.ENABLE_PYPI_PUBLISH == 'true' 49 | || env.ENABLE_TEST_PYPI_PUBLISH == 'true' 50 | run: poetry build 51 | 52 | - name: 🔼 Publish to Test PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | if: ${{ env.ENABLE_TEST_PYPI_PUBLISH == 'true' }} 55 | with: 56 | repository-url: https://test.pypi.org/legacy/ 57 | skip-existing: true 58 | 59 | - name: ☢️ Publish to PyPI 60 | if: ${{ env.ENABLE_PYPI_PUBLISH == 'true' }} 61 | uses: pypa/gh-action-pypi-publish@release/v1 62 | 63 | concurrency: 64 | group: ${{ github.workflow }}-${{ github.ref }} 65 | cancel-in-progress: false 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | env: 5 | ENABLE_COVERAGE: true 6 | 7 | on: 8 | pull_request: 9 | push: 10 | branches: 11 | - main 12 | workflow_dispatch: 13 | 14 | jobs: 15 | Test: 16 | name: Python ${{ matrix.python-version }}, Poetry ${{ matrix.poetry-version }} 17 | 18 | strategy: 19 | matrix: 20 | os: 21 | - Ubuntu 22 | python-version: 23 | - "3.9" 24 | - "3.10" 25 | - "3.11" 26 | - "3.12" 27 | - "3.13" 28 | poetry-version: 29 | - "2.0" 30 | 31 | runs-on: ${{ matrix.os }}-latest 32 | steps: 33 | - name: 💾 Check out repository 34 | uses: actions/checkout@v4 35 | 36 | - name: 🐍 Set up Python project with Poetry 37 | uses: ./.github/workflows/actions/python-poetry 38 | with: 39 | python_version: ${{ matrix.python-version }} 40 | poetry_version: ${{ matrix.poetry-version }} 41 | 42 | - name: 🔥 Test 43 | run: poetry run poe test 44 | 45 | - name: 🚒 Create test summary 46 | uses: test-summary/action@v2 47 | if: success() || failure() 48 | with: 49 | paths: ./.pytest_results.xml 50 | 51 | - name: 📊 Upload coverage to Codecov 52 | uses: codecov/codecov-action@v4 53 | env: 54 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 55 | if: ${{ env.ENABLE_COVERAGE == 'true' }} 56 | with: 57 | fail_ci_if_error: true 58 | files: ./.pytest_coverage.xml 59 | 60 | concurrency: 61 | group: ${{ github.workflow }}-${{ github.ref }} 62 | cancel-in-progress: false 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | .coverage 4 | .pytest_cache/ 5 | .pytest_coverage.xml 6 | .pytest_results.xml 7 | .venv/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-merge-conflict 7 | - id: debug-statements 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/psf/black 11 | rev: 24.10.0 12 | hooks: 13 | - id: black 14 | args: ["--config", "pyproject.toml"] 15 | - repo: https://github.com/pycqa/isort 16 | rev: 5.13.2 17 | hooks: 18 | - id: isort 19 | args: ["--show-config"] 20 | - repo: https://github.com/pycqa/bandit 21 | rev: 1.8.2 22 | hooks: 23 | - id: bandit 24 | additional_dependencies: ['.[toml]'] 25 | args: ["--configfile", "pyproject.toml"] 26 | exclude: '^tests/' 27 | - repo: https://github.com/pycqa/flake8 28 | rev: 7.1.1 29 | hooks: 30 | - id: flake8 31 | additional_dependencies: 32 | - flake8-bugbear 33 | - flake8-pyproject 34 | - flake8-simplify 35 | - pep8-naming 36 | - repo: https://github.com/pycqa/autoflake 37 | rev: v2.3.1 38 | hooks: 39 | - id: autoflake 40 | - repo: https://github.com/asottile/pyupgrade 41 | rev: v3.19.1 42 | hooks: 43 | - id: pyupgrade 44 | args: ["--py39-plus", "--keep-runtime-typing"] 45 | - repo: local 46 | hooks: 47 | - id: mypy 48 | name: mypy 49 | language: python 50 | types_or: [python, pyi] 51 | entry: env -u VIRTUAL_ENV poetry run mypy 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jmapc: A [JMAP][jmapio] client library for Python 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/jmapc)][pypi] 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/jmapc)][pypi] 5 | [![Build](https://img.shields.io/github/checks-status/smkent/jmapc/main?label=build)][gh-actions] 6 | [![codecov](https://codecov.io/gh/smkent/jmapc/branch/main/graph/badge.svg)][codecov] 7 | [![GitHub stars](https://img.shields.io/github/stars/smkent/jmapc?style=social)][repo] 8 | 9 | [![jmapc][logo]](#) 10 | 11 | Currently implemented: 12 | 13 | * Basic models 14 | * Request methods: 15 | * `Core/echo` 16 | * `Email/changes` 17 | * `Email/copy` 18 | * `Email/get` 19 | * `Email/query` 20 | * `Email/queryChanges` 21 | * `Email/set` 22 | * `EmailSubmission/*` (`get`, `changes`, `query`, `queryChanges`, `set`) 23 | * `Identity/*` (`get`, `changes`, `set`) 24 | * `Mailbox/*` (`get`, `changes`, `query`, `queryChanges`, `set`) 25 | * `SearchSnippet/*` (`get`) 26 | * `Thread/*` (`get`, `changes`) 27 | * Arbitrary methods via the `CustomMethod` class 28 | * Fastmail-specific methods: 29 | * [`MaskedEmail/*` (`get`, `set`)][fastmail-maskedemail] 30 | * Combined requests with support for result references 31 | * Basic JMAP method response error handling 32 | * EventSource event handling 33 | * Unit tests for basic functionality and methods 34 | 35 | ## Installation 36 | 37 | [jmapc is available on PyPI][pypi]: 38 | 39 | ```console 40 | pip install jmapc 41 | ``` 42 | 43 | ## Examples 44 | 45 | Any of the included examples can be invoked with `poetry run`: 46 | 47 | ```console 48 | JMAP_HOST=jmap.example.com \ 49 | JMAP_API_TOKEN=ness__pk_fire \ 50 | poetry run examples/identity_get.py 51 | ``` 52 | 53 | If successful, `examples/identity_get.py` should output something like: 54 | 55 | ``` 56 | Identity 12345 is for Ness at ness@onett.example.com 57 | Identity 67890 is for Ness at ness-alternate@onett.example.com 58 | ``` 59 | 60 | ## Development 61 | 62 | ### [Poetry][poetry] installation 63 | 64 | Via [`pipx`][pipx]: 65 | 66 | ```console 67 | pip install pipx 68 | pipx install poetry 69 | pipx inject poetry poetry-pre-commit-plugin 70 | ``` 71 | 72 | Via `pip`: 73 | 74 | ```console 75 | pip install poetry 76 | poetry self add poetry-pre-commit-plugin 77 | ``` 78 | 79 | ### Development tasks 80 | 81 | * Setup: `poetry install` 82 | * Run static checks: `poetry run poe lint` or 83 | `poetry run pre-commit run --all-files` 84 | * Run static checks and tests: `poetry run poe test` 85 | 86 | --- 87 | 88 | Created from [smkent/cookie-python][cookie-python] using 89 | [cookiecutter][cookiecutter] 90 | 91 | [codecov]: https://codecov.io/gh/smkent/jmapc 92 | [cookie-python]: https://github.com/smkent/cookie-python 93 | [cookiecutter]: https://github.com/cookiecutter/cookiecutter 94 | [fastmail-maskedemail]: https://www.fastmail.com/developer/maskedemail/ 95 | [gh-actions]: https://github.com/smkent/jmapc/actions?query=branch%3Amain 96 | [logo]: https://raw.github.com/smkent/jmapc/main/img/jmapc.png 97 | [jmapio]: https://jmap.io 98 | [pipx]: https://pypa.github.io/pipx/ 99 | [poetry]: https://python-poetry.org/docs/#installation 100 | [pypi]: https://pypi.org/project/jmapc/ 101 | [repo]: https://github.com/smkent/jmapc 102 | -------------------------------------------------------------------------------- /examples/create_send_email.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from jmapc import ( 6 | Address, 7 | Client, 8 | Email, 9 | EmailAddress, 10 | EmailBodyPart, 11 | EmailBodyValue, 12 | EmailHeader, 13 | EmailSubmission, 14 | Envelope, 15 | Identity, 16 | MailboxQueryFilterCondition, 17 | Ref, 18 | ) 19 | from jmapc.methods import ( 20 | EmailSet, 21 | EmailSubmissionSet, 22 | EmailSubmissionSetResponse, 23 | IdentityGet, 24 | IdentityGetResponse, 25 | MailboxGet, 26 | MailboxGetResponse, 27 | MailboxQuery, 28 | ) 29 | 30 | TEST_EMAIL_BODY = f""" 31 | Hello from {__file__}! 32 | 33 | If you're reading this email in your inbox, the example worked successfully! 34 | 35 | This email was created with the JMAP API and sent to yourself using the first 36 | identity's email address in your account. 37 | """.strip() 38 | 39 | # Create and configure client 40 | client = Client.create_with_api_token( 41 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 42 | ) 43 | 44 | # Retrieve the Mailbox ID for Drafts 45 | results = client.request( 46 | [ 47 | MailboxQuery(filter=MailboxQueryFilterCondition(name="Drafts")), 48 | MailboxGet(ids=Ref("/ids")), 49 | IdentityGet(), 50 | ] 51 | ) 52 | 53 | # From results, second result, MailboxGet instance, retrieve Mailbox data 54 | assert isinstance( 55 | results[1].response, MailboxGetResponse 56 | ), "Error in Mailbox/get method" 57 | mailbox_data = results[1].response.data 58 | if not mailbox_data: 59 | raise Exception("Drafts not found on the server") 60 | 61 | # From the first mailbox result, retrieve the Mailbox ID 62 | drafts_mailbox_id = mailbox_data[0].id 63 | assert drafts_mailbox_id 64 | 65 | print(f"Drafts has Mailbox ID {drafts_mailbox_id}") 66 | 67 | # From results, third result, IdentityGet instance, retrieve Identity data 68 | assert isinstance( 69 | results[2].response, IdentityGetResponse 70 | ), "Error in Identity/get method" 71 | identity_data = results[2].response.data 72 | if not identity_data: 73 | raise Exception("No identities found on the server") 74 | 75 | # Retrieve the first Identity result 76 | identity = identity_data[0] 77 | assert isinstance(identity, Identity) 78 | 79 | print(f"Found identity with email address {identity.email}") 80 | 81 | # Create and send an email 82 | results = client.request( 83 | [ 84 | # Create a draft email in the Drafts mailbox 85 | EmailSet( 86 | create=dict( 87 | draft=Email( 88 | mail_from=[ 89 | EmailAddress(name=identity.name, email=identity.email) 90 | ], 91 | to=[ 92 | EmailAddress(name=identity.name, email=identity.email) 93 | ], 94 | subject=f"Email created with jmapc's {__file__}", 95 | keywords={"$draft": True}, 96 | mailbox_ids={drafts_mailbox_id: True}, 97 | body_values=dict( 98 | body=EmailBodyValue(value=TEST_EMAIL_BODY) 99 | ), 100 | text_body=[ 101 | EmailBodyPart(part_id="body", type="text/plain") 102 | ], 103 | headers=[ 104 | EmailHeader(name="X-jmapc-example-header", value="yes") 105 | ], 106 | ) 107 | ) 108 | ), 109 | # Send the created draft email, and delete from the Drafts mailbox on 110 | # success 111 | EmailSubmissionSet( 112 | on_success_destroy_email=["#emailToSend"], 113 | create=dict( 114 | emailToSend=EmailSubmission( 115 | email_id="#draft", 116 | identity_id=identity.id, 117 | envelope=Envelope( 118 | mail_from=Address(email=identity.email), 119 | rcpt_to=[Address(email=identity.email)], 120 | ), 121 | ) 122 | ), 123 | ), 124 | ] 125 | ) 126 | # Retrieve EmailSubmission/set method response from method responses 127 | email_send_result = results[1].response 128 | assert isinstance( 129 | email_send_result, EmailSubmissionSetResponse 130 | ), f"Error sending test email: f{email_send_result}" 131 | 132 | # Retrieve sent email metadata from EmailSubmission/set method response 133 | assert email_send_result.created, "Error retrieving sent test email" 134 | sent_data = email_send_result.created["emailToSend"] 135 | assert sent_data, "Error retrieving sent test email data" 136 | 137 | # Print sent email timestamp 138 | print(f"Test email sent to {identity.email} at {sent_data.send_at}") 139 | 140 | # Example output: 141 | # 142 | # Found the mailbox named Inbox with ID deadbeef-0000-0000-0000-000000000001 143 | # Found identity with email address ness@onett.example.com 144 | # Test email sent to ness@onett.example.com at 2022-01-01 12:00:00+00:00 145 | -------------------------------------------------------------------------------- /examples/debug_log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import os 5 | 6 | from jmapc import Client 7 | from jmapc.logging import log 8 | from jmapc.methods import CoreEcho 9 | 10 | # Create basic console logger 11 | logging.basicConfig() 12 | 13 | # Set jmapc log level to DEBUG 14 | log.setLevel(logging.DEBUG) 15 | 16 | # Create and configure client 17 | client = Client.create_with_api_token( 18 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 19 | ) 20 | 21 | # Call JMAP API method 22 | # The request and response JSON content will be logged to the console 23 | client.request(CoreEcho(data=dict(hello="world"))) 24 | 25 | # Example output: 26 | # 27 | # DEBUG:jmapc:Sending JMAP request {"using": ["urn:ietf:params:jmap:core"], "methodCalls": [["Core/echo", {"hello": "world"}, "single.Core/echo"]]} # noqa: E501 28 | # DEBUG:jmapc:Received JMAP response {"methodResponses":[["Core/echo",{"hello":"world"},"single.Core/echo"]]} # noqa: E501 29 | -------------------------------------------------------------------------------- /examples/echo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from jmapc import Client 6 | from jmapc.methods import CoreEcho 7 | 8 | # Create and configure client 9 | client = Client.create_with_api_token( 10 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 11 | ) 12 | 13 | # Prepare a request for the JMAP Core/echo method with some sample data 14 | method = CoreEcho(data=dict(hello="world")) 15 | 16 | # Call JMAP API with the prepared request 17 | result = client.request(method) 18 | 19 | # Print result 20 | print(result) 21 | 22 | # Example output: 23 | # 24 | # CoreEchoResponse(data={'hello': 'world'}) 25 | -------------------------------------------------------------------------------- /examples/event_source.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import collections 4 | import os 5 | from typing import Optional 6 | 7 | from jmapc import Client, Ref, TypeState 8 | from jmapc.methods import EmailChanges, EmailGet, EmailGetResponse 9 | 10 | MAX_EVENTS = 5 11 | 12 | # Create and configure client 13 | client = Client.create_with_api_token( 14 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 15 | ) 16 | 17 | 18 | # Create a callback for email state changes 19 | def email_change_callback( 20 | prev_state: Optional[str], new_state: Optional[str] 21 | ) -> None: 22 | if not prev_state or not new_state: 23 | return 24 | results = client.request( 25 | [EmailChanges(since_state=prev_state), EmailGet(ids=Ref("/created"))] 26 | ) 27 | email_get_response = results[1].response 28 | assert isinstance(email_get_response, EmailGetResponse) 29 | for new_email in email_get_response.data: 30 | to = new_email.to[0].email if new_email.to else "(unknown)" 31 | print(f'Received email for "{to}" with subject "{new_email.subject}"') 32 | 33 | 34 | # Listen for events from the EventSource endpoint 35 | all_prev_state: dict[str, TypeState] = collections.defaultdict(TypeState) 36 | for i, event in enumerate(client.events): 37 | if i >= MAX_EVENTS: 38 | # Exit after receiving MAX_EVENTS events 39 | print(f"Exiting after {i} events") 40 | break 41 | for account_id, new_state in event.data.changed.items(): 42 | prev_state = all_prev_state[account_id] 43 | if new_state != prev_state: 44 | if prev_state.email != new_state.email: 45 | email_change_callback(prev_state.email, new_state.email) 46 | all_prev_state[account_id] = new_state 47 | 48 | # Example output: 49 | # 50 | # Received email for "ness@example.com" with subject "Treasure hunter's cabin" 51 | # Received email for "ness@example.com" with subject "Watch out for The Sharks" 52 | # Received email for "ness@example.com" with subject "Twoson road closed" 53 | # Received email for "ness@example.com" with subject "Big footprint" 54 | # Received email for "ness@example.com" with subject "Can you spare a Cookie?" 55 | # Exiting after 5 events 56 | -------------------------------------------------------------------------------- /examples/identity_get.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from jmapc import Client 6 | from jmapc.methods import IdentityGet, IdentityGetResponse 7 | 8 | # Create and configure client 9 | client = Client.create_with_api_token( 10 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 11 | ) 12 | 13 | # Prepare Identity/get request 14 | # To retrieve all of the user's identities, no arguments are required. 15 | method = IdentityGet() 16 | 17 | # Call JMAP API with the prepared request 18 | result = client.request(method) 19 | 20 | # Print some information about each retrieved identity 21 | assert isinstance(result, IdentityGetResponse), "Error in Identity/get method" 22 | for identity in result.data: 23 | print( 24 | f"Identity {identity.id} is for " 25 | f"{identity.name} at {identity.email}" 26 | ) 27 | 28 | # Example output: 29 | # 30 | # Identity 12345 is for Ness at ness@onett.example.com 31 | # Identity 67890 is for Ness at ness-alternate@onett.example.com 32 | -------------------------------------------------------------------------------- /examples/mailbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from jmapc import Client, MailboxQueryFilterCondition, Ref 6 | from jmapc.methods import MailboxGet, MailboxGetResponse, MailboxQuery 7 | 8 | # Create and configure client 9 | client = Client.create_with_api_token( 10 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 11 | ) 12 | 13 | # Prepare two methods to be submitted in one request 14 | # The first method, Mailbox/query, will locate the ID of the Inbox folder 15 | # The second method, Mailbox/get, uses a result reference to the preceding 16 | # Mailbox/query method to retrieve the Inbox mailbox details 17 | methods = [ 18 | MailboxQuery(filter=MailboxQueryFilterCondition(name="Inbox")), 19 | MailboxGet(ids=Ref("/ids")), 20 | ] 21 | 22 | # Call JMAP API with the prepared request 23 | results = client.request(methods) 24 | 25 | # Retrieve the InvocationResponse for the second method. The InvocationResponse 26 | # contains the client-provided method ID, and the result data model. 27 | method_2_result = results[1] 28 | 29 | # Retrieve the result data model from the InvocationResponse instance 30 | method_2_result_data = method_2_result.response 31 | 32 | # Retrieve the Mailbox data from the result data model 33 | assert isinstance( 34 | method_2_result_data, MailboxGetResponse 35 | ), "Error in Mailbox/get method" 36 | mailboxes = method_2_result_data.data 37 | 38 | # Although multiple mailboxes may be present in the results, we only expect a 39 | # single match for our query. Retrieve the first Mailbox from the list. 40 | mailbox = mailboxes[0] 41 | 42 | # Print some information about the mailbox 43 | print(f"Found the mailbox named {mailbox.name} with ID {mailbox.id}") 44 | print( 45 | f"This mailbox has {mailbox.total_emails} emails, " 46 | f"{mailbox.unread_emails} of which are unread" 47 | ) 48 | 49 | # Example output: 50 | # 51 | # Found the mailbox named Inbox with ID deadbeef-0000-0000-0000-000000000001 52 | # This mailbox has 42 emails, 4 of which are unread 53 | -------------------------------------------------------------------------------- /examples/recent_threads.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from datetime import datetime, timedelta, timezone 5 | 6 | from jmapc import ( 7 | Client, 8 | Comparator, 9 | EmailQueryFilterCondition, 10 | MailboxQueryFilterCondition, 11 | Ref, 12 | ) 13 | from jmapc.methods import ( 14 | EmailGet, 15 | EmailQuery, 16 | MailboxGet, 17 | MailboxGetResponse, 18 | MailboxQuery, 19 | ThreadGet, 20 | ThreadGetResponse, 21 | ) 22 | 23 | # Create and configure client 24 | client = Client.create_with_api_token( 25 | host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] 26 | ) 27 | 28 | # Retrieve the Mailbox ID for the Inbox 29 | results = client.request( 30 | [ 31 | MailboxQuery(filter=MailboxQueryFilterCondition(name="Inbox")), 32 | MailboxGet(ids=Ref("/ids")), 33 | ] 34 | ) 35 | # From results, second result, MailboxGet instance, retrieve Mailbox data 36 | assert isinstance( 37 | results[1].response, MailboxGetResponse 38 | ), "Error in Mailbox/get method" 39 | mailbox_data = results[1].response.data 40 | if not mailbox_data: 41 | raise Exception("Inbox not found on the server") 42 | 43 | # From the first mailbox result, retrieve the Mailbox ID 44 | mailbox_id = mailbox_data[0].id 45 | assert mailbox_id 46 | 47 | print(f"Inbox has Mailbox ID {mailbox_id}") 48 | 49 | # Search for the 5 most recent thread IDs in the Inbox, limited to emails 50 | # received within the last 7 days 51 | results = client.request( 52 | [ 53 | # Find email IDs for emails in the Inbox 54 | EmailQuery( 55 | collapse_threads=True, 56 | filter=EmailQueryFilterCondition( 57 | in_mailbox=mailbox_id, 58 | after=datetime.now(tz=timezone.utc) - timedelta(days=7), 59 | ), 60 | sort=[Comparator(property="receivedAt", is_ascending=False)], 61 | limit=5, 62 | ), 63 | # Use Email/query results to retrieve thread IDs for each email ID 64 | EmailGet(ids=Ref("/ids"), properties=["threadId"]), 65 | # Use Email/get results to retrieve email counts for each thread ID 66 | ThreadGet(ids=Ref("/list/*/threadId")), 67 | ] 68 | ) 69 | 70 | # From results, third result, ThreadGet instance, retrieve Threads data 71 | assert isinstance( 72 | results[2].response, ThreadGetResponse 73 | ), "Error in Thread/get method" 74 | for thread in results[2].response.data: 75 | print(f"Thread {thread.id} has {len(thread.email_ids)} emails") 76 | 77 | # Example output: 78 | # 79 | # Inbox has Mailbox ID deadbeef-0000-0000-0000-000000000001 80 | # Thread Tdeadbeefdeadbeef has 16 emails 81 | # Thread Tc01dc0ffee15c01d has 2 emails 82 | # Thread Tf00df00df00df00d has 98 emails 83 | # Thread T0ffbea70ddba1100 has 1 emails 84 | # Thread Tf0071e55f007ba11 has 7 emails 85 | -------------------------------------------------------------------------------- /img/jmapc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smkent/jmapc/f8200fa4d2294f9aa83d9389cfe1e059f3628164/img/jmapc.png -------------------------------------------------------------------------------- /jmapc/__init__.py: -------------------------------------------------------------------------------- 1 | from . import auth, errors, fastmail, methods, models 2 | from .__version__ import __version__ as version 3 | from .client import Client, ClientError, EventSourceConfig 4 | from .errors import Error 5 | from .methods import Request, ResponseOrError 6 | from .models import ( 7 | AddedItem, 8 | Address, 9 | Blob, 10 | Comparator, 11 | Delivered, 12 | DeliveryStatus, 13 | Displayed, 14 | Email, 15 | EmailAddress, 16 | EmailBodyPart, 17 | EmailBodyValue, 18 | EmailHeader, 19 | EmailQueryFilter, 20 | EmailQueryFilterCondition, 21 | EmailQueryFilterOperator, 22 | EmailSubmission, 23 | EmailSubmissionQueryFilter, 24 | EmailSubmissionQueryFilterCondition, 25 | EmailSubmissionQueryFilterOperator, 26 | Envelope, 27 | Event, 28 | Identity, 29 | ListOrRef, 30 | Mailbox, 31 | MailboxQueryFilter, 32 | MailboxQueryFilterCondition, 33 | MailboxQueryFilterOperator, 34 | Operator, 35 | SearchSnippet, 36 | SetError, 37 | StateChange, 38 | StrOrRef, 39 | Thread, 40 | TypeState, 41 | UndoStatus, 42 | ) 43 | from .ref import Ref, ResultReference 44 | 45 | __all__ = [ 46 | "AddedItem", 47 | "Address", 48 | "Blob", 49 | "Client", 50 | "ClientError", 51 | "Comparator", 52 | "Delivered", 53 | "DeliveryStatus", 54 | "Displayed", 55 | "Email", 56 | "EmailAddress", 57 | "EmailBodyPart", 58 | "EmailBodyValue", 59 | "EmailHeader", 60 | "EmailQueryFilter", 61 | "EmailQueryFilterCondition", 62 | "EmailQueryFilterOperator", 63 | "EmailSubmission", 64 | "EmailSubmissionQueryFilter", 65 | "EmailSubmissionQueryFilterCondition", 66 | "EmailSubmissionQueryFilterOperator", 67 | "Envelope", 68 | "Error", 69 | "Event", 70 | "EventSourceConfig", 71 | "Identity", 72 | "ListOrRef", 73 | "Mailbox", 74 | "MailboxQueryFilter", 75 | "MailboxQueryFilterCondition", 76 | "MailboxQueryFilterOperator", 77 | "Operator", 78 | "Ref", 79 | "Request", 80 | "ResponseOrError", 81 | "ResultReference", 82 | "SearchSnippet", 83 | "SetError", 84 | "StateChange", 85 | "StrOrRef", 86 | "Thread", 87 | "TypeState", 88 | "UndoStatus", 89 | "auth", 90 | "fastmail", 91 | "errors", 92 | "log", 93 | "methods", 94 | "models", 95 | "version", 96 | ] 97 | -------------------------------------------------------------------------------- /jmapc/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0" 2 | -------------------------------------------------------------------------------- /jmapc/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import ( 5 | Any, 6 | Callable, 7 | Union, 8 | cast, 9 | ) 10 | from collections.abc import Sequence 11 | 12 | from dataclasses_json import config 13 | 14 | from . import constants, errors 15 | from .methods import ( 16 | CustomResponse, 17 | Invocation, 18 | InvocationResponseOrError, 19 | Method, 20 | Request, 21 | Response, 22 | ResponseOrError, 23 | ) 24 | from .serializer import Model 25 | 26 | 27 | def decode_method_responses( 28 | value: Sequence[tuple[str, dict[str, Any], str]], 29 | ) -> list[InvocationResponseOrError]: 30 | def _response_type(method_name: str) -> type[ResponseOrError]: 31 | if method_name == "error": 32 | return errors.Error 33 | return Response.response_types.get(method_name, CustomResponse) 34 | 35 | return [ 36 | InvocationResponseOrError( 37 | id=method_id, 38 | response=_response_type(name).from_dict(response), 39 | ) 40 | for name, response, method_id in value 41 | ] 42 | 43 | 44 | @dataclass 45 | class APIResponse(Model): 46 | session_state: str 47 | method_responses: list[InvocationResponseOrError] = field( 48 | metadata=config( 49 | encoder=lambda value: None, 50 | decoder=decode_method_responses, 51 | ), 52 | ) 53 | created_ids: list[str] = field(default_factory=list) 54 | 55 | 56 | @dataclass 57 | class APIRequest(Model): 58 | account_id: str = field( 59 | repr=False, 60 | metadata=config(exclude=cast(Callable[..., bool], lambda *_: True)), 61 | ) 62 | method_calls: list[tuple[str, Any, str]] 63 | using: set[str] = field( 64 | init=False, 65 | default_factory=lambda: {constants.JMAP_URN_CORE}, 66 | metadata=config(encoder=lambda value: sorted(list(value))), 67 | ) 68 | 69 | @staticmethod 70 | def from_calls( 71 | account_id: str, 72 | calls: Union[Sequence[Request], Sequence[Method], Method], 73 | ) -> APIRequest: 74 | calls_list = calls if isinstance(calls, Sequence) else [calls] 75 | invocations: list[Invocation] = [] 76 | # Create Invocations for Methods 77 | for i, c in enumerate(calls_list): 78 | if isinstance(c, Invocation): 79 | invocations.append(c) 80 | continue 81 | call_id = i if len(calls_list) > 1 else "single" 82 | invocations.append( 83 | Invocation(id=f"{call_id}.{c.jmap_method_name}", method=c) 84 | ) 85 | # Build method calls list from Invocations 86 | method_calls = [ 87 | ( 88 | c.method.jmap_method_name, 89 | c.method.to_dict( 90 | account_id=account_id, 91 | method_calls_slice=invocations[:i], 92 | encode_json=False, 93 | ), 94 | c.id, 95 | ) 96 | for i, c in enumerate(invocations) 97 | ] 98 | api_request = APIRequest( 99 | account_id=account_id, method_calls=method_calls 100 | ) 101 | api_request.using |= set().union( 102 | *[c.method.using for c in invocations] 103 | ) 104 | return api_request 105 | -------------------------------------------------------------------------------- /jmapc/auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class BearerAuth(requests.auth.AuthBase): 5 | def __init__(self, api_token: str): 6 | self.api_token = api_token 7 | 8 | def __call__( 9 | self, request: requests.models.PreparedRequest 10 | ) -> requests.models.PreparedRequest: 11 | request.headers["Authorization"] = f"Bearer {self.api_token}" 12 | return request 13 | -------------------------------------------------------------------------------- /jmapc/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import mimetypes 5 | from collections.abc import Generator, Sequence 6 | from dataclasses import asdict, dataclass 7 | from pathlib import Path 8 | from typing import Any, Literal, Optional, TypeVar, Union, cast, overload 9 | 10 | import requests 11 | import sseclient 12 | 13 | from . import errors 14 | from .api import APIRequest, APIResponse 15 | from .auth import BearerAuth 16 | from .logging import log 17 | from .methods import ( 18 | InvocationResponse, 19 | InvocationResponseOrError, 20 | Method, 21 | Request, 22 | Response, 23 | ResponseOrError, 24 | ) 25 | from .models import Blob, EmailBodyPart, Event 26 | from .session import Session 27 | 28 | RequestsAuth = Union[requests.auth.AuthBase, tuple[str, str]] 29 | ClientType = TypeVar("ClientType", bound="Client") 30 | 31 | REQUEST_TIMEOUT = 30 32 | 33 | 34 | @dataclass 35 | class EventSourceConfig: 36 | types: str = "*" 37 | closeafter: Literal["state", "no"] = "no" 38 | ping: int = 0 39 | 40 | 41 | class ClientError(RuntimeError): 42 | def __init__( 43 | self, 44 | *args: Any, 45 | result: Sequence[Union[InvocationResponse, InvocationResponseOrError]], 46 | **kwargs: Any, 47 | ): 48 | super().__init__(*args, **kwargs) 49 | self.result = result 50 | 51 | 52 | class Client: 53 | @classmethod 54 | def create_with_api_token( 55 | cls: type[ClientType], 56 | host: str, 57 | api_token: str, 58 | *args: Any, 59 | **kwargs: Any, 60 | ) -> ClientType: 61 | kwargs["auth"] = BearerAuth(api_token) 62 | return cls(host, *args, **kwargs) 63 | 64 | @classmethod 65 | def create_with_password( 66 | cls: type[ClientType], 67 | host: str, 68 | user: str, 69 | password: str, 70 | *args: Any, 71 | **kwargs: Any, 72 | ) -> ClientType: 73 | kwargs["auth"] = requests.auth.HTTPBasicAuth( 74 | username=user, password=password 75 | ) 76 | return cls(host, *args, **kwargs) 77 | 78 | def __init__( 79 | self, 80 | host: str, 81 | auth: Optional[RequestsAuth] = None, 82 | last_event_id: Optional[str] = None, 83 | event_source_config: Optional[EventSourceConfig] = None, 84 | ) -> None: 85 | self._host: str = host 86 | self._auth: Optional[RequestsAuth] = auth 87 | self._last_event_id: Optional[str] = last_event_id 88 | self._event_source_config: EventSourceConfig = ( 89 | event_source_config or EventSourceConfig() 90 | ) 91 | self._events: Optional[sseclient.SSEClient] = None 92 | 93 | @property 94 | def events(self) -> Generator[Event, None, None]: 95 | if not self._events: 96 | self._events = sseclient.SSEClient( 97 | self.jmap_session.event_source_url.format( 98 | **asdict(self._event_source_config) 99 | ), 100 | auth=self.requests_session.auth, 101 | last_id=self._last_event_id, 102 | ) 103 | for event in self._events: 104 | if event.event != "state": 105 | continue 106 | yield Event.load_from_sseclient_event(event) 107 | 108 | @functools.cached_property 109 | def requests_session(self) -> requests.Session: 110 | requests_session = requests.Session() 111 | requests_session.auth = self._auth 112 | return requests_session 113 | 114 | @functools.cached_property 115 | def jmap_session(self) -> Session: 116 | r = self.requests_session.get( 117 | f"https://{self._host}/.well-known/jmap", timeout=REQUEST_TIMEOUT 118 | ) 119 | r.raise_for_status() 120 | session = Session.from_dict(r.json()) 121 | log.debug(f"Retrieved JMAP session with state {session.state}") 122 | return session 123 | 124 | @property 125 | def account_id(self) -> str: 126 | primary_account_id = ( 127 | self.jmap_session.primary_accounts.core 128 | or self.jmap_session.primary_accounts.mail 129 | or self.jmap_session.primary_accounts.submission 130 | ) 131 | if not primary_account_id: 132 | raise Exception("No primary account ID found") 133 | return primary_account_id 134 | 135 | def upload_blob(self, file_name: Union[str, Path]) -> Blob: 136 | mime_type, mime_encoding = mimetypes.guess_type(file_name) 137 | upload_url = self.jmap_session.upload_url.format( 138 | accountId=self.account_id 139 | ) 140 | with open(file_name, "rb") as f: 141 | r = self.requests_session.post( 142 | upload_url, 143 | stream=True, 144 | data=f, 145 | headers={"Content-Type": mime_type}, 146 | timeout=REQUEST_TIMEOUT, 147 | ) 148 | r.raise_for_status() 149 | return Blob.from_dict(r.json()) 150 | 151 | def download_attachment( 152 | self, 153 | attachment: EmailBodyPart, 154 | file_name: Union[str, Path], 155 | ) -> None: 156 | if not file_name: 157 | raise Exception("Destination file name is required") 158 | file_name = Path(file_name) 159 | blob_url = self.jmap_session.download_url.format( 160 | accountId=self.account_id, 161 | blobId=attachment.blob_id, 162 | name=attachment.name, 163 | type=attachment.type, 164 | ) 165 | r = self.requests_session.get( 166 | blob_url, stream=True, timeout=REQUEST_TIMEOUT 167 | ) 168 | r.raise_for_status() 169 | with open(file_name, "wb") as f: 170 | f.write(r.raw.data) 171 | 172 | @overload 173 | def request( 174 | self, 175 | calls: Method, 176 | raise_errors: Literal[False] = False, 177 | single_response: Literal[True] = True, 178 | ) -> ResponseOrError: ... # pragma: no cover 179 | 180 | @overload 181 | def request( 182 | self, 183 | calls: Method, 184 | raise_errors: Literal[False] = False, 185 | single_response: Literal[False] = False, 186 | ) -> Union[ 187 | Sequence[ResponseOrError], ResponseOrError 188 | ]: ... # pragma: no cover 189 | 190 | @overload 191 | def request( 192 | self, 193 | calls: Method, 194 | raise_errors: Literal[True], 195 | single_response: Literal[True], 196 | ) -> Response: ... # pragma: no cover 197 | 198 | @overload 199 | def request( 200 | self, 201 | calls: Method, 202 | raise_errors: Literal[True], 203 | single_response: Literal[False] = False, 204 | ) -> Union[Sequence[Response], Response]: ... # pragma: no cover 205 | 206 | @overload 207 | def request( 208 | self, 209 | calls: Sequence[Request], 210 | raise_errors: Literal[False] = False, 211 | ) -> Sequence[InvocationResponse]: ... # pragma: no cover 212 | 213 | @overload 214 | def request( 215 | self, 216 | calls: Sequence[Request], 217 | raise_errors: Literal[True], 218 | ) -> Sequence[InvocationResponse]: ... # pragma: no cover 219 | 220 | def request( 221 | self, 222 | calls: Union[Sequence[Request], Sequence[Method], Method], 223 | raise_errors: bool = False, 224 | single_response: bool = False, 225 | ) -> Union[ 226 | Sequence[InvocationResponseOrError], 227 | Sequence[InvocationResponse], 228 | Union[Sequence[ResponseOrError], ResponseOrError], 229 | Union[Sequence[Response], Response], 230 | ]: 231 | if isinstance(calls, list) and single_response: 232 | raise ValueError( 233 | "single_response cannot be used with " 234 | "multiple JMAP request methods" 235 | ) 236 | api_request = APIRequest.from_calls(self.account_id, calls) 237 | # Validate all requested JMAP URNs are supported by the server 238 | unsupported_urns = ( 239 | api_request.using - self.jmap_session.capabilities.urns 240 | ) 241 | if unsupported_urns: 242 | log.warning( 243 | "URNs in request are not in server capabilities: " 244 | f"{', '.join(sorted(unsupported_urns))}" 245 | ) 246 | # Execute request 247 | result: Union[ 248 | Sequence[InvocationResponseOrError], Sequence[InvocationResponse] 249 | ] = self._api_request(api_request) 250 | if raise_errors: 251 | if any(isinstance(r.response, errors.Error) for r in result): 252 | raise ClientError( 253 | "Errors found in method responses", result=result 254 | ) 255 | result = [ 256 | InvocationResponse( 257 | id=r.id, response=cast(Response, r.response) 258 | ) 259 | for r in result 260 | ] 261 | if isinstance(calls, Method): 262 | if len(result) > 1: 263 | if single_response: 264 | raise ClientError( 265 | f"{len(result)} method responses received for single" 266 | f" method call {api_request.method_calls[0][0]}", 267 | result=result, 268 | ) 269 | return [r.response for r in result] 270 | return result[0].response 271 | return result 272 | 273 | def _api_request( 274 | self, request: APIRequest 275 | ) -> Sequence[InvocationResponseOrError]: 276 | raw_request = request.to_json() 277 | log.debug(f"Sending JMAP request {raw_request}") 278 | r = self.requests_session.post( 279 | self.jmap_session.api_url, 280 | headers={"Content-Type": "application/json"}, 281 | data=raw_request, 282 | timeout=REQUEST_TIMEOUT, 283 | ) 284 | r.raise_for_status() 285 | log.debug(f"Received JMAP response {r.text}") 286 | api_response = APIResponse.from_dict(r.json()) 287 | if api_response.session_state != self.jmap_session.state: 288 | log.debug( 289 | "JMAP response session state" 290 | f' "{api_response.session_state}" differs from cached state' 291 | f'"{self.jmap_session.state}", invalidating cached state' 292 | ) 293 | del self.jmap_session 294 | return api_response.method_responses 295 | -------------------------------------------------------------------------------- /jmapc/constants.py: -------------------------------------------------------------------------------- 1 | JMAP_URN_CORE = "urn:ietf:params:jmap:core" 2 | JMAP_URN_MAIL = "urn:ietf:params:jmap:mail" 3 | JMAP_URN_SUBMISSION = "urn:ietf:params:jmap:submission" 4 | -------------------------------------------------------------------------------- /jmapc/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Optional, cast 5 | 6 | from .serializer import Model 7 | 8 | __all__ = ["Error", "ServerFail"] 9 | 10 | 11 | class ErrorCollector(Model): 12 | error_types: dict[str, type[Error]] = {} 13 | 14 | @classmethod 15 | def __init_subclass__(cls) -> None: 16 | error_class = cast(type["Error"], cls) 17 | type_attr = getattr(error_class, "_type", None) 18 | if type_attr: 19 | ErrorCollector.error_types[type_attr] = error_class 20 | 21 | 22 | @dataclass 23 | class Error(ErrorCollector): 24 | type: str = "" 25 | 26 | def __post_init__(self) -> None: 27 | type_attr = getattr(self, "_type", None) 28 | if type_attr: 29 | self.type = type_attr 30 | 31 | @classmethod 32 | def from_dict(cls, *args: Any, **kwargs: Any) -> Error: 33 | res = super().from_dict(*args, **kwargs) 34 | if cls == Error: 35 | res_type = ErrorCollector.error_types.get(res.type) 36 | if res_type: 37 | return res_type.from_dict(*args, **kwargs) 38 | return res 39 | return res 40 | 41 | 42 | @dataclass 43 | class AccountNotFound(Error): 44 | _type = "accountNotFound" 45 | 46 | 47 | @dataclass 48 | class AccountNotSupportedByMethod(Error): 49 | _type = "accountNotSupportedByMethod" 50 | 51 | 52 | @dataclass 53 | class AccountReadOnly(Error): 54 | _type = "accountReadOnly" 55 | 56 | 57 | @dataclass 58 | class CannotCalculateChanges(Error): 59 | _type = "cannotCalculateChanges" 60 | 61 | 62 | @dataclass 63 | class InvalidArguments(Error): 64 | _type = "invalidArguments" 65 | arguments: Optional[list[str]] = None 66 | description: Optional[str] = None 67 | 68 | 69 | @dataclass 70 | class InvalidResultReference(Error): 71 | _type = "invalidResultReference" 72 | 73 | 74 | @dataclass 75 | class Forbidden(Error): 76 | _type = "forbidden" 77 | 78 | 79 | @dataclass 80 | class RequestTooLarge(Error): 81 | _type = "requestTooLarge" 82 | 83 | 84 | @dataclass 85 | class ServerFail(Error): 86 | _type = "serverFail" 87 | description: Optional[str] = None 88 | 89 | 90 | @dataclass 91 | class ServerPartialFail(Error): 92 | _type = "serverPartialFail" 93 | 94 | 95 | @dataclass 96 | class ServerUnavailable(Error): 97 | _type = "serverUnavailable" 98 | 99 | 100 | @dataclass 101 | class UnknownMethod(Error): 102 | _type = "unknownMethod" 103 | 104 | 105 | @dataclass 106 | class UnsupportedFilter(Error): 107 | _type = "UnsupportedFilter" 108 | -------------------------------------------------------------------------------- /jmapc/fastmail/__init__.py: -------------------------------------------------------------------------------- 1 | from .maskedemail_methods import ( 2 | MaskedEmailGet, 3 | MaskedEmailGetResponse, 4 | MaskedEmailSet, 5 | MaskedEmailSetResponse, 6 | ) 7 | from .maskedemail_models import MaskedEmail, MaskedEmailState 8 | 9 | __all__ = [ 10 | "MaskedEmail", 11 | "MaskedEmailGet", 12 | "MaskedEmailGetResponse", 13 | "MaskedEmailSet", 14 | "MaskedEmailSetResponse", 15 | "MaskedEmailState", 16 | ] 17 | -------------------------------------------------------------------------------- /jmapc/fastmail/maskedemail_methods.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from ..methods.base import Get, GetResponse, Set, SetResponse 9 | from .maskedemail_models import MaskedEmail 10 | 11 | URN = "https://www.fastmail.com/dev/maskedemail" 12 | 13 | 14 | class MaskedEmailBase: 15 | method_namespace: Optional[str] = "MaskedEmail" 16 | using = {URN} 17 | 18 | 19 | @dataclass 20 | class MaskedEmailGet(MaskedEmailBase, Get): 21 | pass 22 | 23 | 24 | @dataclass 25 | class MaskedEmailGetResponse(MaskedEmailBase, GetResponse): 26 | data: list[MaskedEmail] = field(metadata=config(field_name="list")) 27 | 28 | 29 | @dataclass 30 | class MaskedEmailSet(MaskedEmailBase, Set): 31 | pass 32 | 33 | 34 | @dataclass 35 | class MaskedEmailSetResponse(MaskedEmailBase, SetResponse): 36 | created: Optional[dict[str, Optional[MaskedEmail]]] 37 | updated: Optional[dict[str, Optional[MaskedEmail]]] 38 | -------------------------------------------------------------------------------- /jmapc/fastmail/maskedemail_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import datetime 5 | from enum import Enum 6 | from typing import Optional 7 | 8 | from dataclasses_json import config 9 | 10 | from ..serializer import Model, datetime_decode, datetime_encode 11 | 12 | 13 | class MaskedEmailState(Enum): 14 | PENDING = "pending" 15 | ENABLED = "enabled" 16 | DISABLED = "disabled" 17 | DELETED = "deleted" 18 | 19 | 20 | @dataclass 21 | class MaskedEmail(Model): 22 | id: Optional[str] = None 23 | email: Optional[str] = None 24 | state: Optional[MaskedEmailState] = None 25 | for_domain: Optional[str] = None 26 | description: Optional[str] = None 27 | last_message_at: Optional[datetime] = field( 28 | default=None, 29 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 30 | ) 31 | created_at: Optional[datetime] = field( 32 | default=None, 33 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 34 | ) 35 | created_by: Optional[str] = None 36 | url: Optional[str] = None 37 | email_prefix: Optional[str] = None 38 | -------------------------------------------------------------------------------- /jmapc/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Set default logging handler to avoid "No handler found" warnings. 4 | log = logging.getLogger(__package__) 5 | log.addHandler(logging.NullHandler()) 6 | -------------------------------------------------------------------------------- /jmapc/methods/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | Invocation, 3 | InvocationResponse, 4 | InvocationResponseOrError, 5 | Method, 6 | Request, 7 | Response, 8 | ResponseOrError, 9 | ) 10 | from .core import CoreEcho, CoreEchoResponse 11 | from .custom import CustomMethod, CustomResponse 12 | from .email import ( 13 | EmailChanges, 14 | EmailChangesResponse, 15 | EmailCopy, 16 | EmailCopyResponse, 17 | EmailGet, 18 | EmailGetResponse, 19 | EmailQuery, 20 | EmailQueryChanges, 21 | EmailQueryChangesResponse, 22 | EmailQueryResponse, 23 | EmailSet, 24 | EmailSetResponse, 25 | ) 26 | from .email_submission import ( 27 | EmailSubmissionChanges, 28 | EmailSubmissionChangesResponse, 29 | EmailSubmissionGet, 30 | EmailSubmissionGetResponse, 31 | EmailSubmissionQuery, 32 | EmailSubmissionQueryChanges, 33 | EmailSubmissionQueryChangesResponse, 34 | EmailSubmissionQueryResponse, 35 | EmailSubmissionSet, 36 | EmailSubmissionSetResponse, 37 | ) 38 | from .identity import ( 39 | IdentityChanges, 40 | IdentityChangesResponse, 41 | IdentityGet, 42 | IdentityGetResponse, 43 | IdentitySet, 44 | IdentitySetResponse, 45 | ) 46 | from .mailbox import ( 47 | MailboxChanges, 48 | MailboxChangesResponse, 49 | MailboxGet, 50 | MailboxGetResponse, 51 | MailboxQuery, 52 | MailboxQueryChanges, 53 | MailboxQueryChangesResponse, 54 | MailboxQueryResponse, 55 | MailboxSet, 56 | MailboxSetResponse, 57 | ) 58 | from .search_snippet import SearchSnippetGet, SearchSnippetGetResponse 59 | from .thread import ( 60 | ThreadChanges, 61 | ThreadChangesResponse, 62 | ThreadGet, 63 | ThreadGetResponse, 64 | ) 65 | 66 | __all__ = [ 67 | "CoreEcho", 68 | "CoreEchoResponse", 69 | "CustomMethod", 70 | "CustomResponse", 71 | "EmailChanges", 72 | "EmailChangesResponse", 73 | "EmailCopy", 74 | "EmailCopyResponse", 75 | "EmailGet", 76 | "EmailGetResponse", 77 | "EmailQuery", 78 | "EmailQueryChanges", 79 | "EmailQueryChangesResponse", 80 | "EmailQueryResponse", 81 | "EmailSet", 82 | "EmailSetResponse", 83 | "EmailSubmissionChanges", 84 | "EmailSubmissionChangesResponse", 85 | "EmailSubmissionGet", 86 | "EmailSubmissionGetResponse", 87 | "EmailSubmissionQuery", 88 | "EmailSubmissionQueryChanges", 89 | "EmailSubmissionQueryChangesResponse", 90 | "EmailSubmissionQueryResponse", 91 | "EmailSubmissionSet", 92 | "EmailSubmissionSetResponse", 93 | "IdentityChanges", 94 | "IdentityChangesResponse", 95 | "IdentityGet", 96 | "IdentityGetResponse", 97 | "IdentitySet", 98 | "IdentitySetResponse", 99 | "Invocation", 100 | "InvocationResponse", 101 | "InvocationResponseOrError", 102 | "MailboxChanges", 103 | "MailboxChangesResponse", 104 | "MailboxGet", 105 | "MailboxGetResponse", 106 | "MailboxQuery", 107 | "MailboxQueryChanges", 108 | "MailboxQueryChangesResponse", 109 | "MailboxQueryResponse", 110 | "MailboxSet", 111 | "MailboxSetResponse", 112 | "Method", 113 | "Request", 114 | "Response", 115 | "ResponseOrError", 116 | "SearchSnippetGet", 117 | "SearchSnippetGetResponse", 118 | "ThreadChanges", 119 | "ThreadChangesResponse", 120 | "ThreadGet", 121 | "ThreadGetResponse", 122 | ] 123 | -------------------------------------------------------------------------------- /jmapc/methods/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from dataclasses import dataclass, field 5 | from typing import Any, Optional 6 | from typing import Set as SetType 7 | from typing import Union, cast 8 | 9 | from ..errors import Error 10 | from ..models import AddedItem, Comparator, ListOrRef, SetError, StrOrRef 11 | from ..serializer import Model 12 | 13 | 14 | class MethodBase(Model): 15 | using: SetType[str] = set() 16 | method_namespace: Optional[str] = None 17 | 18 | @property 19 | def jmap_method_name(self) -> str: 20 | return getattr(self, "jmap_method", None) or self.get_method_name() 21 | 22 | @classmethod 23 | def get_method_name(cls) -> str: 24 | if not cls.method_namespace: 25 | raise ValueError(f"Method {cls.__class__} has no method namespace") 26 | method_type = getattr(cls, "method_type", None) 27 | if not method_type: 28 | raise ValueError(f"Method {cls.__class__} has no method type") 29 | return f"{cls.method_namespace}/{method_type}" 30 | 31 | 32 | class Method(MethodBase): 33 | pass 34 | 35 | 36 | @dataclass 37 | class MethodWithAccount(Method): 38 | account_id: Optional[str] = field(init=False, default=None) 39 | 40 | 41 | class ResponseCollector(MethodBase): 42 | response_types: dict[str, type[Union[Error, Response]]] = {} 43 | 44 | @classmethod 45 | def __init_subclass__(cls) -> None: 46 | with contextlib.suppress(ValueError): 47 | method_name = cls.get_method_name() 48 | ResponseCollector.response_types[method_name] = cast( 49 | type[Response], cls 50 | ) 51 | 52 | 53 | @dataclass 54 | class Response(ResponseCollector): 55 | pass 56 | 57 | 58 | @dataclass 59 | class ResponseWithAccount(Response): 60 | account_id: Optional[str] 61 | 62 | 63 | @dataclass 64 | class InvocationBase: 65 | id: str 66 | 67 | 68 | @dataclass 69 | class Invocation(InvocationBase): 70 | method: Method 71 | 72 | 73 | @dataclass 74 | class InvocationResponse(InvocationBase): 75 | response: Response 76 | 77 | 78 | @dataclass 79 | class InvocationResponseOrError(InvocationBase): 80 | response: Union[Error, Response] 81 | 82 | 83 | class ChangesMethod: 84 | method_type: Optional[str] = "changes" 85 | 86 | 87 | @dataclass 88 | class Changes(MethodWithAccount, ChangesMethod): 89 | since_state: str 90 | max_changes: Optional[int] = None 91 | 92 | 93 | @dataclass 94 | class ChangesResponse(ResponseWithAccount, ChangesMethod): 95 | old_state: str 96 | new_state: str 97 | has_more_changes: bool 98 | created: list[str] 99 | updated: list[str] 100 | destroyed: list[str] 101 | 102 | 103 | class CopyMethod: 104 | method_type: Optional[str] = "copy" 105 | 106 | 107 | @dataclass 108 | class Copy(MethodWithAccount, CopyMethod): 109 | from_account_id: str 110 | if_from_in_state: Optional[str] = None 111 | if_in_state: Optional[str] = None 112 | on_success_destroy_original: bool = False 113 | destroy_from_if_in_state: Optional[str] = None 114 | 115 | 116 | @dataclass 117 | class CopyResponse(ResponseWithAccount, CopyMethod): 118 | from_account_id: str 119 | old_state: str 120 | new_state: str 121 | not_created: Optional[dict[str, SetError]] 122 | 123 | 124 | class GetMethod: 125 | method_type: Optional[str] = "get" 126 | 127 | 128 | @dataclass 129 | class Get(MethodWithAccount, GetMethod): 130 | ids: Optional[ListOrRef[str]] 131 | properties: Optional[list[str]] = None 132 | 133 | 134 | @dataclass 135 | class GetResponseWithoutState(ResponseWithAccount, GetMethod): 136 | not_found: Optional[list[str]] 137 | 138 | 139 | @dataclass 140 | class GetResponse(GetResponseWithoutState): 141 | state: Optional[str] 142 | 143 | 144 | class SetMethod: 145 | method_type: Optional[str] = "set" 146 | 147 | 148 | @dataclass 149 | class Set(MethodWithAccount, SetMethod): 150 | if_in_state: Optional[StrOrRef] = None 151 | create: Optional[dict[str, Any]] = None 152 | update: Optional[dict[str, dict[str, Any]]] = None 153 | destroy: Optional[ListOrRef[str]] = None 154 | 155 | 156 | @dataclass 157 | class SetResponse(ResponseWithAccount, SetMethod): 158 | old_state: Optional[str] 159 | new_state: Optional[str] 160 | created: Optional[dict[str, Any]] 161 | updated: Optional[dict[str, Any]] 162 | destroyed: Optional[list[str]] 163 | not_created: Optional[dict[str, SetError]] = None 164 | not_updated: Optional[dict[str, SetError]] = None 165 | not_destroyed: Optional[dict[str, SetError]] = None 166 | 167 | 168 | class QueryMethod: 169 | method_type: Optional[str] = "query" 170 | 171 | 172 | @dataclass 173 | class Query(MethodWithAccount, QueryMethod): 174 | sort: Optional[list[Comparator]] = None 175 | position: Optional[int] = None 176 | anchor: Optional[str] = None 177 | anchor_offset: Optional[int] = None 178 | limit: Optional[int] = None 179 | calculate_total: Optional[bool] = None 180 | 181 | 182 | @dataclass 183 | class QueryResponse(ResponseWithAccount, QueryMethod): 184 | query_state: str 185 | can_calculate_changes: bool 186 | position: int 187 | ids: ListOrRef[str] 188 | total: Optional[int] = None 189 | limit: Optional[int] = None 190 | 191 | 192 | class QueryChangesMethod: 193 | method_type: Optional[str] = "queryChanges" 194 | 195 | 196 | @dataclass 197 | class QueryChanges(MethodWithAccount, QueryChangesMethod): 198 | sort: Optional[list[Comparator]] = None 199 | since_query_state: Optional[str] = None 200 | max_changes: Optional[int] = None 201 | up_to_id: Optional[str] = None 202 | calculate_total: bool = False 203 | 204 | 205 | @dataclass 206 | class QueryChangesResponse(ResponseWithAccount, QueryChangesMethod): 207 | old_query_state: str 208 | new_query_state: str 209 | removed: list[str] 210 | added: list[AddedItem] 211 | total: Optional[int] = None 212 | 213 | 214 | ResponseOrError = Union[Error, Response] 215 | Request = Union[Method, Invocation] 216 | -------------------------------------------------------------------------------- /jmapc/methods/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Optional 5 | 6 | from .. import constants 7 | from .base import Method, Response 8 | 9 | 10 | class CoreBase: 11 | method_namespace: Optional[str] = "Core" 12 | using = {constants.JMAP_URN_CORE} 13 | 14 | 15 | class EchoMethod: 16 | method_type: Optional[str] = "echo" 17 | 18 | 19 | @dataclass 20 | class CoreEcho(CoreBase, EchoMethod, Method): 21 | def to_dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]: 22 | return self.data or dict() 23 | 24 | data: Optional[dict[str, Any]] = None 25 | 26 | 27 | @dataclass 28 | class CoreEchoResponse(CoreBase, EchoMethod, Response): 29 | data: Optional[dict[str, Any]] = None 30 | 31 | @classmethod 32 | def from_dict( 33 | cls, kvs: Any, *args: Any, **kwargs: Any 34 | ) -> CoreEchoResponse: 35 | return CoreEchoResponse(data=kvs) 36 | -------------------------------------------------------------------------------- /jmapc/methods/custom.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Optional 5 | 6 | from .base import MethodWithAccount, ResponseWithAccount 7 | 8 | 9 | @dataclass 10 | class CustomMethod(MethodWithAccount): 11 | def __post_init__(self) -> None: 12 | self.jmap_method = "" 13 | self.using = set() 14 | 15 | def to_dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]: 16 | return self.data or dict() 17 | 18 | data: Optional[dict[str, Any]] = None 19 | 20 | 21 | @dataclass 22 | class CustomResponse(ResponseWithAccount): 23 | data: Optional[dict[str, Any]] = None 24 | 25 | def __post_init__(self) -> None: 26 | if self.data and "accountId" in self.data: 27 | del self.data["accountId"] 28 | 29 | @classmethod 30 | def from_dict(cls, kvs: Any, *args: Any, **kwargs: Any) -> CustomResponse: 31 | account_id = kvs.pop("accountId") 32 | return CustomResponse(account_id=account_id, data=kvs) 33 | -------------------------------------------------------------------------------- /jmapc/methods/email.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from .. import constants 9 | from ..models import Email, EmailQueryFilter 10 | from .base import ( 11 | Changes, 12 | ChangesResponse, 13 | Copy, 14 | CopyResponse, 15 | Get, 16 | GetResponse, 17 | Query, 18 | QueryChanges, 19 | QueryChangesResponse, 20 | QueryResponse, 21 | Set, 22 | SetResponse, 23 | ) 24 | 25 | 26 | class EmailBase: 27 | method_namespace: Optional[str] = "Email" 28 | using = {constants.JMAP_URN_MAIL} 29 | 30 | 31 | @dataclass 32 | class EmailChanges(EmailBase, Changes): 33 | pass 34 | 35 | 36 | @dataclass 37 | class EmailChangesResponse(EmailBase, ChangesResponse): 38 | pass 39 | 40 | 41 | @dataclass 42 | class EmailCopy(EmailBase, Copy): 43 | create: Optional[dict[str, Email]] = None 44 | 45 | 46 | @dataclass 47 | class EmailCopyResponse(EmailBase, CopyResponse): 48 | created: Optional[dict[str, Email]] = None 49 | 50 | 51 | @dataclass 52 | class EmailGet(EmailBase, Get): 53 | body_properties: Optional[list[str]] = None 54 | fetch_text_body_values: Optional[bool] = None 55 | fetch_html_body_values: Optional[bool] = field( 56 | metadata=config(field_name="fetchHTMLBodyValues"), default=None 57 | ) 58 | fetch_all_body_values: Optional[bool] = None 59 | max_body_value_bytes: Optional[int] = None 60 | 61 | 62 | @dataclass 63 | class EmailGetResponse(EmailBase, GetResponse): 64 | data: list[Email] = field(metadata=config(field_name="list")) 65 | 66 | 67 | @dataclass 68 | class EmailQuery(EmailBase, Query): 69 | filter: Optional[EmailQueryFilter] = None 70 | collapse_threads: Optional[bool] = None 71 | 72 | 73 | @dataclass 74 | class EmailQueryResponse(EmailBase, QueryResponse): 75 | pass 76 | 77 | 78 | @dataclass 79 | class EmailQueryChanges(EmailBase, QueryChanges): 80 | filter: Optional[EmailQueryFilter] = None 81 | collapse_threads: Optional[bool] = None 82 | 83 | 84 | @dataclass 85 | class EmailQueryChangesResponse(EmailBase, QueryChangesResponse): 86 | pass 87 | 88 | 89 | @dataclass 90 | class EmailSet(EmailBase, Set): 91 | create: Optional[dict[str, Email]] = None 92 | 93 | 94 | @dataclass 95 | class EmailSetResponse(EmailBase, SetResponse): 96 | created: Optional[dict[str, Optional[Email]]] 97 | updated: Optional[dict[str, Optional[Email]]] 98 | -------------------------------------------------------------------------------- /jmapc/methods/email_submission.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Any, Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from .. import constants 9 | from ..models import EmailSubmission, EmailSubmissionQueryFilter 10 | from .base import ( 11 | Changes, 12 | ChangesResponse, 13 | Get, 14 | GetResponse, 15 | Query, 16 | QueryChanges, 17 | QueryChangesResponse, 18 | QueryResponse, 19 | Set, 20 | SetResponse, 21 | ) 22 | 23 | 24 | class EmailSubmissionBase: 25 | method_namespace: Optional[str] = "EmailSubmission" 26 | using = {constants.JMAP_URN_SUBMISSION} 27 | 28 | 29 | @dataclass 30 | class EmailSubmissionChanges(EmailSubmissionBase, Changes): 31 | pass 32 | 33 | 34 | @dataclass 35 | class EmailSubmissionChangesResponse(EmailSubmissionBase, ChangesResponse): 36 | pass 37 | 38 | 39 | @dataclass 40 | class EmailSubmissionGet(EmailSubmissionBase, Get): 41 | pass 42 | 43 | 44 | @dataclass 45 | class EmailSubmissionGetResponse(EmailSubmissionBase, GetResponse): 46 | data: list[EmailSubmission] = field(metadata=config(field_name="list")) 47 | 48 | 49 | @dataclass 50 | class EmailSubmissionQuery(EmailSubmissionBase, Query): 51 | filter: Optional[EmailSubmissionQueryFilter] = None 52 | 53 | 54 | @dataclass 55 | class EmailSubmissionQueryResponse(EmailSubmissionBase, QueryResponse): 56 | pass 57 | 58 | 59 | @dataclass 60 | class EmailSubmissionQueryChanges(EmailSubmissionBase, QueryChanges): 61 | filter: Optional[EmailSubmissionQueryFilter] = None 62 | 63 | 64 | @dataclass 65 | class EmailSubmissionQueryChangesResponse( 66 | EmailSubmissionBase, QueryChangesResponse 67 | ): 68 | pass 69 | 70 | 71 | @dataclass 72 | class EmailSubmissionSet(EmailSubmissionBase, Set): 73 | create: Optional[dict[str, EmailSubmission]] = None 74 | on_success_update_email: Optional[dict[str, Any]] = None 75 | on_success_destroy_email: Optional[list[str]] = None 76 | 77 | 78 | @dataclass 79 | class EmailSubmissionSetResponse(EmailSubmissionBase, SetResponse): 80 | created: Optional[dict[str, Optional[EmailSubmission]]] 81 | updated: Optional[dict[str, Optional[EmailSubmission]]] 82 | -------------------------------------------------------------------------------- /jmapc/methods/identity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from .. import constants 9 | from ..models import Identity, ListOrRef 10 | from .base import Changes, ChangesResponse, Get, GetResponse, Set, SetResponse 11 | 12 | 13 | class IdentityBase: 14 | method_namespace: Optional[str] = "Identity" 15 | using = {constants.JMAP_URN_SUBMISSION} 16 | 17 | 18 | @dataclass 19 | class IdentityChanges(IdentityBase, Changes): 20 | pass 21 | 22 | 23 | @dataclass 24 | class IdentityChangesResponse(IdentityBase, ChangesResponse): 25 | pass 26 | 27 | 28 | @dataclass 29 | class IdentityGet(IdentityBase, Get): 30 | ids: Optional[ListOrRef[str]] = None 31 | 32 | 33 | @dataclass 34 | class IdentityGetResponse(IdentityBase, GetResponse): 35 | data: list[Identity] = field(metadata=config(field_name="list")) 36 | 37 | 38 | @dataclass 39 | class IdentitySet(IdentityBase, Set): 40 | create: Optional[dict[str, Identity]] = None 41 | 42 | 43 | @dataclass 44 | class IdentitySetResponse(IdentityBase, SetResponse): 45 | created: Optional[dict[str, Optional[Identity]]] 46 | updated: Optional[dict[str, Optional[Identity]]] 47 | -------------------------------------------------------------------------------- /jmapc/methods/mailbox.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from .. import constants 9 | from ..models import Mailbox, MailboxQueryFilter 10 | from .base import ( 11 | Changes, 12 | ChangesResponse, 13 | Get, 14 | GetResponse, 15 | Query, 16 | QueryChanges, 17 | QueryChangesResponse, 18 | QueryResponse, 19 | Set, 20 | SetResponse, 21 | ) 22 | 23 | 24 | class MailboxBase: 25 | method_namespace: Optional[str] = "Mailbox" 26 | using = {constants.JMAP_URN_MAIL} 27 | 28 | 29 | @dataclass 30 | class MailboxChanges(MailboxBase, Changes): 31 | pass 32 | 33 | 34 | @dataclass 35 | class MailboxChangesResponse(MailboxBase, ChangesResponse): 36 | pass 37 | 38 | 39 | @dataclass 40 | class MailboxGet(MailboxBase, Get): 41 | pass 42 | 43 | 44 | @dataclass 45 | class MailboxGetResponse(MailboxBase, GetResponse): 46 | data: list[Mailbox] = field(metadata=config(field_name="list")) 47 | 48 | 49 | @dataclass 50 | class MailboxQuery(MailboxBase, Query): 51 | filter: Optional[MailboxQueryFilter] = None 52 | sort_as_tree: bool = False 53 | filter_as_tree: bool = False 54 | 55 | 56 | @dataclass 57 | class MailboxQueryResponse(MailboxBase, QueryResponse): 58 | pass 59 | 60 | 61 | @dataclass 62 | class MailboxQueryChanges(MailboxBase, QueryChanges): 63 | filter: Optional[MailboxQueryFilter] = None 64 | 65 | 66 | @dataclass 67 | class MailboxQueryChangesResponse(MailboxBase, QueryChangesResponse): 68 | pass 69 | 70 | 71 | @dataclass 72 | class MailboxSet(MailboxBase, Set): 73 | create: Optional[dict[str, Mailbox]] = None 74 | on_destroy_remove_emails: bool = False 75 | 76 | 77 | @dataclass 78 | class MailboxSetResponse(MailboxBase, SetResponse): 79 | created: Optional[dict[str, Optional[Mailbox]]] 80 | updated: Optional[dict[str, Optional[Mailbox]]] 81 | -------------------------------------------------------------------------------- /jmapc/methods/search_snippet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from .. import constants 9 | from ..models import EmailQueryFilter, ListOrRef, SearchSnippet, TypeOrRef 10 | from .base import Get, GetResponseWithoutState 11 | 12 | 13 | class SearchSnippetBase: 14 | method_namespace: Optional[str] = "SearchSnippet" 15 | using = {constants.JMAP_URN_MAIL} 16 | 17 | 18 | @dataclass 19 | class SearchSnippetGet(SearchSnippetBase, Get): 20 | ids: Optional[ListOrRef[str]] = field( 21 | metadata=config(field_name="emailIds"), default=None 22 | ) 23 | filter: Optional[TypeOrRef[EmailQueryFilter]] = None 24 | 25 | 26 | @dataclass 27 | class SearchSnippetGetResponse(SearchSnippetBase, GetResponseWithoutState): 28 | data: list[SearchSnippet] = field(metadata=config(field_name="list")) 29 | -------------------------------------------------------------------------------- /jmapc/methods/thread.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional 5 | 6 | from dataclasses_json import config 7 | 8 | from .. import constants 9 | from ..models import Thread 10 | from .base import Changes, ChangesResponse, Get, GetResponse 11 | 12 | 13 | class ThreadBase: 14 | method_namespace: Optional[str] = "Thread" 15 | using = {constants.JMAP_URN_MAIL} 16 | 17 | 18 | @dataclass 19 | class ThreadChanges(ThreadBase, Changes): 20 | pass 21 | 22 | 23 | @dataclass 24 | class ThreadChangesResponse(ThreadBase, ChangesResponse): 25 | pass 26 | 27 | 28 | @dataclass 29 | class ThreadGet(ThreadBase, Get): 30 | pass 31 | 32 | 33 | @dataclass 34 | class ThreadGetResponse(ThreadBase, GetResponse): 35 | data: list[Thread] = field(metadata=config(field_name="list")) 36 | -------------------------------------------------------------------------------- /jmapc/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .email import ( 2 | Email, 3 | EmailBodyPart, 4 | EmailBodyValue, 5 | EmailHeader, 6 | EmailQueryFilter, 7 | EmailQueryFilterCondition, 8 | EmailQueryFilterOperator, 9 | ) 10 | from .email_submission import ( 11 | Address, 12 | Delivered, 13 | DeliveryStatus, 14 | Displayed, 15 | EmailSubmission, 16 | EmailSubmissionQueryFilter, 17 | EmailSubmissionQueryFilterCondition, 18 | EmailSubmissionQueryFilterOperator, 19 | Envelope, 20 | UndoStatus, 21 | ) 22 | from .event import Event, StateChange, TypeState 23 | from .identity import Identity 24 | from .mailbox import ( 25 | Mailbox, 26 | MailboxQueryFilter, 27 | MailboxQueryFilterCondition, 28 | MailboxQueryFilterOperator, 29 | ) 30 | from .models import ( 31 | AddedItem, 32 | Blob, 33 | Comparator, 34 | EmailAddress, 35 | ListOrRef, 36 | Operator, 37 | SetError, 38 | StrOrRef, 39 | TypeOrRef, 40 | ) 41 | from .search_snippet import SearchSnippet 42 | from .thread import Thread 43 | 44 | __all__ = [ 45 | "AddedItem", 46 | "Address", 47 | "Blob", 48 | "Comparator", 49 | "Delivered", 50 | "DeliveryStatus", 51 | "Displayed", 52 | "Email", 53 | "EmailAddress", 54 | "EmailBodyPart", 55 | "EmailBodyValue", 56 | "EmailHeader", 57 | "EmailQueryFilter", 58 | "EmailQueryFilterCondition", 59 | "EmailQueryFilterOperator", 60 | "EmailSubmission", 61 | "EmailSubmissionQueryFilter", 62 | "EmailSubmissionQueryFilterCondition", 63 | "EmailSubmissionQueryFilterOperator", 64 | "Envelope", 65 | "Event", 66 | "Identity", 67 | "ListOrRef", 68 | "Mailbox", 69 | "MailboxQueryFilter", 70 | "MailboxQueryFilterCondition", 71 | "MailboxQueryFilterOperator", 72 | "Operator", 73 | "SearchSnippet", 74 | "SetError", 75 | "StateChange", 76 | "StrOrRef", 77 | "Thread", 78 | "TypeOrRef", 79 | "TypeState", 80 | "UndoStatus", 81 | ] 82 | -------------------------------------------------------------------------------- /jmapc/models/email.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import datetime 5 | from typing import Optional, Union 6 | 7 | from dataclasses_json import config 8 | 9 | from ..serializer import Model, datetime_decode, datetime_encode 10 | from .models import EmailAddress, ListOrRef, Operator, StrOrRef 11 | 12 | 13 | @dataclass 14 | class Email(Model): 15 | id: Optional[str] = field(metadata=config(field_name="id"), default=None) 16 | blob_id: Optional[str] = None 17 | thread_id: Optional[str] = None 18 | mailbox_ids: Optional[dict[str, bool]] = None 19 | keywords: Optional[dict[str, bool]] = None 20 | size: Optional[int] = None 21 | received_at: Optional[datetime] = field( 22 | default=None, 23 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 24 | ) 25 | message_id: Optional[list[str]] = None 26 | in_reply_to: Optional[list[str]] = None 27 | references: Optional[list[str]] = None 28 | headers: Optional[list[EmailHeader]] = None 29 | mail_from: Optional[list[EmailAddress]] = field( 30 | metadata=config(field_name="from"), default=None 31 | ) 32 | to: Optional[list[EmailAddress]] = None 33 | cc: Optional[list[EmailAddress]] = None 34 | bcc: Optional[list[EmailAddress]] = None 35 | reply_to: Optional[list[EmailAddress]] = None 36 | subject: Optional[str] = None 37 | sent_at: Optional[datetime] = field( 38 | default=None, 39 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 40 | ) 41 | body_structure: Optional[EmailBodyPart] = None 42 | body_values: Optional[dict[str, EmailBodyValue]] = None 43 | text_body: Optional[list[EmailBodyPart]] = None 44 | html_body: Optional[list[EmailBodyPart]] = None 45 | attachments: Optional[list[EmailBodyPart]] = None 46 | has_attachment: Optional[bool] = None 47 | preview: Optional[str] = None 48 | 49 | 50 | @dataclass 51 | class EmailHeader(Model): 52 | name: Optional[str] = None 53 | value: Optional[str] = None 54 | 55 | 56 | @dataclass 57 | class EmailBodyPart(Model): 58 | part_id: Optional[str] = None 59 | blob_id: Optional[str] = None 60 | size: Optional[int] = None 61 | headers: Optional[list[EmailHeader]] = None 62 | name: Optional[str] = None 63 | type: Optional[str] = None 64 | charset: Optional[str] = None 65 | disposition: Optional[str] = None 66 | cid: Optional[str] = None 67 | language: Optional[list[str]] = None 68 | location: Optional[str] = None 69 | sub_parts: Optional[list[EmailBodyPart]] = None 70 | 71 | 72 | @dataclass 73 | class EmailBodyValue(Model): 74 | value: Optional[str] = None 75 | is_encoding_problem: Optional[bool] = None 76 | is_truncated: Optional[bool] = None 77 | 78 | 79 | @dataclass 80 | class EmailQueryFilterCondition(Model): 81 | in_mailbox: Optional[StrOrRef] = None 82 | in_mailbox_other_than: Optional[ListOrRef[str]] = None 83 | before: Optional[datetime] = field( 84 | default=None, 85 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 86 | ) 87 | after: Optional[datetime] = field( 88 | default=None, 89 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 90 | ) 91 | min_size: Optional[int] = None 92 | max_size: Optional[int] = None 93 | all_in_thread_have_keyword: Optional[StrOrRef] = None 94 | some_in_thread_have_keyword: Optional[StrOrRef] = None 95 | none_in_thread_have_keyword: Optional[StrOrRef] = None 96 | has_keyword: Optional[StrOrRef] = None 97 | not_keyword: Optional[StrOrRef] = None 98 | has_attachment: Optional[bool] = None 99 | text: Optional[StrOrRef] = None 100 | mail_from: Optional[str] = field( 101 | metadata=config(field_name="from"), default=None 102 | ) 103 | to: Optional[StrOrRef] = None 104 | cc: Optional[StrOrRef] = None 105 | bcc: Optional[StrOrRef] = None 106 | body: Optional[StrOrRef] = None 107 | header: Optional[ListOrRef[str]] = None 108 | 109 | 110 | @dataclass 111 | class EmailQueryFilterOperator(Model): 112 | operator: Operator 113 | conditions: list[EmailQueryFilter] 114 | 115 | 116 | EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] 117 | -------------------------------------------------------------------------------- /jmapc/models/email_submission.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import datetime 5 | from enum import Enum 6 | from typing import Optional, Union 7 | 8 | from dataclasses_json import DataClassJsonMixin, config 9 | 10 | from ..serializer import Model, datetime_decode, datetime_encode 11 | from .models import Operator 12 | 13 | 14 | @dataclass 15 | class EmailSubmission(Model): 16 | id: Optional[str] = field(metadata=config(field_name="id"), default=None) 17 | identity_id: Optional[str] = None 18 | email_id: Optional[str] = None 19 | thread_id: Optional[str] = None 20 | envelope: Optional[Envelope] = None 21 | send_at: Optional[datetime] = field( 22 | default=None, 23 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 24 | ) 25 | undo_status: Optional[UndoStatus] = None 26 | delivery_status: Optional[dict[str, DeliveryStatus]] = None 27 | dsn_blob_ids: Optional[list[str]] = None 28 | mdn_blob_ids: Optional[list[str]] = None 29 | 30 | 31 | @dataclass 32 | class Envelope(Model): 33 | mail_from: Optional[Address] = None 34 | rcpt_to: Optional[list[Address]] = None 35 | 36 | 37 | @dataclass 38 | class Address(DataClassJsonMixin): 39 | email: Optional[str] = None 40 | parameters: Optional[dict[str, str]] = None 41 | 42 | 43 | class UndoStatus(Enum): 44 | PENDING = "pending" 45 | FINAL = "final" 46 | CANCELED = "canceled" 47 | 48 | 49 | @dataclass 50 | class DeliveryStatus(Model): 51 | smtp_reply: str 52 | delivered: Delivered 53 | displayed: Displayed 54 | 55 | 56 | class Delivered(Enum): 57 | QUEUED = "queued" 58 | YES = "yes" 59 | NO = "no" 60 | UNKNOWN = "unknown" 61 | 62 | 63 | class Displayed(Enum): 64 | UNKNOWN = "unknown" 65 | YES = "yes" 66 | 67 | 68 | @dataclass 69 | class EmailSubmissionQueryFilterCondition(Model): 70 | identity_ids: Optional[list[str]] = None 71 | email_ids: Optional[list[str]] = None 72 | thread_ids: Optional[list[str]] = None 73 | undo_status: Optional[UndoStatus] = None 74 | before: Optional[datetime] = field( 75 | default=None, 76 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 77 | ) 78 | after: Optional[datetime] = field( 79 | default=None, 80 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 81 | ) 82 | 83 | 84 | @dataclass 85 | class EmailSubmissionQueryFilterOperator(Model): 86 | operator: Operator 87 | conditions: list[EmailSubmissionQueryFilter] 88 | 89 | 90 | EmailSubmissionQueryFilter = Union[ 91 | EmailSubmissionQueryFilterCondition, EmailSubmissionQueryFilterOperator 92 | ] 93 | -------------------------------------------------------------------------------- /jmapc/models/event.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass, field 3 | from typing import Optional 4 | 5 | import sseclient 6 | from dataclasses_json import config 7 | 8 | from ..serializer import Model 9 | 10 | 11 | @dataclass 12 | class TypeState(Model): 13 | calendar_event: Optional[str] = field( 14 | metadata=config(field_name="CalendarEvent"), default=None 15 | ) 16 | mailbox: Optional[str] = field( 17 | metadata=config(field_name="Mailbox"), default=None 18 | ) 19 | email: Optional[str] = field( 20 | metadata=config(field_name="Email"), default=None 21 | ) 22 | email_delivery: Optional[str] = field( 23 | metadata=config(field_name="EmailDelivery"), default=None 24 | ) 25 | thread: Optional[str] = field( 26 | metadata=config(field_name="Thread"), default=None 27 | ) 28 | 29 | 30 | @dataclass 31 | class StateChange(Model): 32 | changed: dict[str, TypeState] 33 | type: Optional[str] = None 34 | 35 | 36 | @dataclass 37 | class Event(Model): 38 | id: Optional[str] 39 | data: StateChange 40 | 41 | @classmethod 42 | def load_from_sseclient_event(cls, event: sseclient.Event) -> "Event": 43 | data = json.loads(event.data) 44 | return cls.from_dict({"id": event.id, "data": data}) 45 | -------------------------------------------------------------------------------- /jmapc/models/identity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Optional 5 | 6 | from ..serializer import Model 7 | from .models import EmailAddress 8 | 9 | 10 | @dataclass 11 | class Identity(Model): 12 | name: str 13 | email: str 14 | reply_to: Optional[str] 15 | bcc: Optional[list[EmailAddress]] 16 | text_signature: Optional[str] 17 | html_signature: Optional[str] 18 | may_delete: bool 19 | id: Optional[str] = None 20 | -------------------------------------------------------------------------------- /jmapc/models/mailbox.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional, Union 5 | 6 | from dataclasses_json import config 7 | 8 | from ..serializer import Model 9 | from .models import Operator, StrOrRef 10 | 11 | 12 | @dataclass 13 | class Mailbox(Model): 14 | id: Optional[str] = field(metadata=config(field_name="id"), default=None) 15 | name: Optional[str] = None 16 | sort_order: Optional[int] = 0 17 | total_emails: Optional[int] = None 18 | unread_emails: Optional[int] = None 19 | total_threads: Optional[int] = None 20 | unread_threads: Optional[int] = None 21 | is_subscribed: Optional[bool] = False 22 | role: Optional[str] = None 23 | parent_id: Optional[str] = field( 24 | metadata=config(field_name="parentId"), default=None 25 | ) 26 | 27 | 28 | @dataclass 29 | class MailboxQueryFilterCondition(Model): 30 | name: Optional[StrOrRef] = None 31 | role: Optional[StrOrRef] = None 32 | parent_id: Optional[StrOrRef] = None 33 | has_any_role: Optional[bool] = None 34 | is_subscribed: Optional[bool] = None 35 | 36 | 37 | @dataclass 38 | class MailboxQueryFilterOperator(Model): 39 | operator: Operator 40 | conditions: list[MailboxQueryFilter] 41 | 42 | 43 | MailboxQueryFilter = Union[ 44 | MailboxQueryFilterCondition, MailboxQueryFilterOperator 45 | ] 46 | -------------------------------------------------------------------------------- /jmapc/models/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from enum import Enum 5 | from typing import Optional, TypeVar, Union 6 | 7 | from dataclasses_json import config 8 | 9 | from ..ref import Ref, ResultReference 10 | from ..serializer import Model 11 | 12 | T = TypeVar("T") 13 | StrOrRef = Union[str, ResultReference, Ref] 14 | ListOrRef = Union[list[T], ResultReference, Ref] 15 | TypeOrRef = Union[T, ResultReference, Ref] 16 | 17 | 18 | @dataclass 19 | class Blob(Model): 20 | id: str = field(metadata=config(field_name="blobId")) 21 | type: str 22 | size: int 23 | 24 | 25 | @dataclass 26 | class AddedItem(Model): 27 | id: str = field(metadata=config(field_name="id")) 28 | index: int 29 | 30 | 31 | @dataclass 32 | class EmailAddress(Model): 33 | name: Optional[str] = None 34 | email: Optional[str] = None 35 | 36 | 37 | @dataclass 38 | class Comparator(Model): 39 | property: str 40 | is_ascending: bool = True 41 | collation: Optional[str] = None 42 | anchor: Optional[str] = None 43 | anchor_offset: int = 0 44 | limit: Optional[int] = None 45 | calculate_total: bool = False 46 | position: int = 0 47 | 48 | 49 | @dataclass 50 | class FilterOperator(Model): 51 | operator: Operator 52 | 53 | 54 | class Operator(Enum): 55 | AND = "AND" 56 | OR = "OR" 57 | NOT = "NOT" 58 | 59 | 60 | @dataclass 61 | class SetError(Model): 62 | type: str 63 | description: Optional[str] = None 64 | already_exists: Optional[str] = None 65 | not_found: Optional[list[str]] = None 66 | properties: Optional[list[str]] = None 67 | -------------------------------------------------------------------------------- /jmapc/models/search_snippet.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from ..serializer import Model 5 | 6 | 7 | @dataclass 8 | class SearchSnippet(Model): 9 | email_id: str 10 | subject: Optional[str] = None 11 | preview: Optional[str] = None 12 | -------------------------------------------------------------------------------- /jmapc/models/thread.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from ..serializer import Model 6 | 7 | 8 | @dataclass 9 | class Thread(Model): 10 | def __len__(self) -> int: 11 | return len(self.email_ids) 12 | 13 | id: str 14 | email_ids: list[str] 15 | -------------------------------------------------------------------------------- /jmapc/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smkent/jmapc/f8200fa4d2294f9aa83d9389cfe1e059f3628164/jmapc/py.typed -------------------------------------------------------------------------------- /jmapc/ref.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Union 3 | 4 | import dataclasses_json 5 | from dataclasses_json import config 6 | 7 | REF_SENTINEL_KEY = "__ref" 8 | 9 | 10 | @dataclass 11 | class ResultReference(dataclasses_json.DataClassJsonMixin): 12 | dataclass_json_config = dataclasses_json.config( 13 | letter_case=dataclasses_json.LetterCase.CAMEL, 14 | )["dataclasses_json"] 15 | name: str 16 | path: str 17 | result_of: str 18 | _ref_sentinel: str = field( 19 | init=False, 20 | default="ResultReference", 21 | metadata=config(field_name=REF_SENTINEL_KEY), 22 | ) 23 | 24 | 25 | @dataclass 26 | class Ref(dataclasses_json.DataClassJsonMixin): 27 | path: str 28 | method: Union[str, int] = -1 29 | _ref_sentinel: str = field( 30 | init=False, 31 | default="Ref", 32 | metadata=config(field_name=REF_SENTINEL_KEY), 33 | ) 34 | -------------------------------------------------------------------------------- /jmapc/serializer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from datetime import datetime 5 | from typing import TYPE_CHECKING, Any, Optional, cast 6 | 7 | import dataclasses_json 8 | import dateutil.parser 9 | 10 | from .ref import REF_SENTINEL_KEY, Ref, ResultReference 11 | 12 | if TYPE_CHECKING: 13 | from .methods import Invocation # pragma: no cover 14 | 15 | 16 | def datetime_encode(dt: datetime) -> str: 17 | return f"{dt.replace(tzinfo=None).isoformat()}Z" 18 | 19 | 20 | def datetime_decode(value: Optional[str]) -> Optional[datetime]: 21 | if not value: 22 | return None 23 | return dateutil.parser.isoparse(value) 24 | 25 | 26 | class ModelToDictPostprocessor: 27 | def __init__( 28 | self, method_calls_slice: Optional[list[Invocation]] = None 29 | ) -> None: 30 | self.method_calls_slice = method_calls_slice 31 | 32 | def postprocess( 33 | self, 34 | data: dict[str, dataclasses_json.core.Json], 35 | ) -> dict[str, dataclasses_json.core.Json]: 36 | for key in [key for key in data.keys() if not key.startswith("#")]: 37 | value = data[key] 38 | if isinstance(value, dict): 39 | if REF_SENTINEL_KEY in value: 40 | with contextlib.suppress(KeyError): 41 | data = self.fix_result_reference(data, key) 42 | continue 43 | data[key] = self.postprocess(value) 44 | elif ( 45 | key == "headers" 46 | and isinstance(value, list) 47 | and len(value) > 0 48 | and isinstance(value[0], dict) 49 | and set(value[0].keys()) == {"name", "value"} 50 | ): 51 | data = self.fix_email_headers(data, key, value) 52 | return data 53 | 54 | def resolve_ref_target(self, ref: Ref) -> int: 55 | assert self.method_calls_slice 56 | if isinstance(ref.method, int): 57 | return ref.method 58 | if isinstance(ref.method, str): 59 | for i, m in enumerate(self.method_calls_slice): 60 | if m.id == ref.method: 61 | return i 62 | raise IndexError(f'Call "{ref.method}" for reference not found') 63 | 64 | def ref_to_result_reference(self, ref: Ref) -> ResultReference: 65 | if not self.method_calls_slice: 66 | raise ValueError("No previous calls for reference") 67 | ref_target = self.resolve_ref_target(ref) 68 | return ResultReference( 69 | name=self.method_calls_slice[ref_target].method.jmap_method_name, 70 | path=ref.path, 71 | result_of=self.method_calls_slice[ref_target].id, 72 | ) 73 | 74 | def fix_result_reference( 75 | self, 76 | data: dict[str, dataclasses_json.core.Json], 77 | key: str, 78 | ) -> dict[str, dataclasses_json.core.Json]: 79 | ref_type = cast(dict[str, Any], data[key]).get(REF_SENTINEL_KEY) 80 | if ref_type == "ResultReference": 81 | rr = ResultReference.from_dict(data[key]) 82 | elif ref_type == "Ref": 83 | rr = self.ref_to_result_reference(Ref.from_dict(data[key])) 84 | else: 85 | raise ValueError( 86 | f"Unexpected reference sentinel value: {ref_type}" 87 | ) 88 | data[key] = rr.to_dict() 89 | # Replace existing key with #-prefixed key 90 | new_key = f"#{key}" 91 | data[new_key] = data[key] 92 | del data[key] 93 | # Remove ref sentinel key from serialized output 94 | new_data = data[new_key] 95 | assert isinstance(new_data, dict) 96 | del new_data[REF_SENTINEL_KEY] 97 | return data 98 | 99 | def fix_email_headers( 100 | self, 101 | data: dict[str, dataclasses_json.core.Json], 102 | key: str, 103 | value: list[dict[str, str]], 104 | ) -> dict[str, dataclasses_json.core.Json]: 105 | for header in value: 106 | header_key = header["name"] 107 | header_value = header["value"] 108 | data[f"header:{header_key}"] = header_value 109 | del data[key] 110 | return data 111 | 112 | 113 | class Model(dataclasses_json.DataClassJsonMixin): 114 | dataclass_json_config = dataclasses_json.config( 115 | letter_case=dataclasses_json.LetterCase.CAMEL, 116 | undefined=dataclasses_json.Undefined.EXCLUDE, 117 | exclude=lambda f: f is None, 118 | )["dataclasses_json"] 119 | 120 | def to_dict( 121 | self, 122 | *args: Any, 123 | account_id: Optional[str] = None, 124 | method_calls_slice: Optional[list[Invocation]] = None, 125 | **kwargs: Any, 126 | ) -> dict[str, dataclasses_json.core.Json]: 127 | if account_id: 128 | self.account_id: Optional[str] = account_id 129 | todict = ModelToDictPostprocessor(method_calls_slice) 130 | return todict.postprocess(super().to_dict(*args, **kwargs)) 131 | -------------------------------------------------------------------------------- /jmapc/session.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from dataclasses import dataclass, field 3 | from typing import Optional 4 | 5 | from dataclasses_json import CatchAll, Undefined, config, dataclass_json 6 | 7 | from . import constants 8 | from .serializer import Model 9 | 10 | 11 | @dataclass 12 | class Session(Model): 13 | username: str 14 | api_url: str 15 | download_url: str 16 | upload_url: str 17 | event_source_url: str 18 | state: str 19 | primary_accounts: "SessionPrimaryAccount" 20 | capabilities: "SessionCapabilities" 21 | 22 | 23 | @dataclass_json(undefined=Undefined.INCLUDE) 24 | @dataclass 25 | class SessionCapabilities(Model): 26 | # dataclasses_json.CatchAll Currently does not work with 27 | # from __future__ import annotations 28 | core: "SessionCapabilitiesCore" = field( 29 | metadata=config(field_name=constants.JMAP_URN_CORE) 30 | ) 31 | extensions: CatchAll = field(default_factory=dict) 32 | 33 | @functools.cached_property 34 | def urns(self) -> set[str]: 35 | return set(self.to_dict().keys()) 36 | 37 | 38 | @dataclass 39 | class SessionCapabilitiesCore(Model): 40 | max_size_upload: int 41 | max_concurrent_upload: int 42 | max_size_request: int 43 | max_concurrent_requests: int 44 | max_calls_in_request: int 45 | max_objects_in_get: int 46 | max_objects_in_set: int 47 | collation_algorithms: set[str] 48 | 49 | 50 | @dataclass 51 | class SessionPrimaryAccount(Model): 52 | core: Optional[str] = field( 53 | metadata=config(field_name=constants.JMAP_URN_CORE), default=None 54 | ) 55 | mail: Optional[str] = field( 56 | metadata=config(field_name=constants.JMAP_URN_MAIL), default=None 57 | ) 58 | submission: Optional[str] = field( 59 | metadata=config(field_name=constants.JMAP_URN_SUBMISSION), default=None 60 | ) 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [project] 6 | name = "jmapc" 7 | description = "JMAP client library for Python" 8 | license = "GPL-3.0-or-later" 9 | authors = [ { name = "Stephen Kent", email = "smkent@smkent.net" } ] 10 | readme = "README.md" 11 | repository = "https://github.com/smkent/jmapc" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Operating System :: OS Independent", 15 | "Topic :: Communications :: Email :: Post-Office", 16 | "Topic :: Office/Business :: Groupware", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "Typing :: Typed", 19 | ] 20 | requires-python = ">=3.9,<4.0" 21 | dynamic = [ "version" ] 22 | dependencies = [ 23 | "brotli (>=1.0.9)", 24 | "dataclasses-json", 25 | "python-dateutil", 26 | "requests", 27 | "sseclient", 28 | ] 29 | include = [ 30 | "examples", 31 | ] 32 | 33 | [project.scripts] 34 | 35 | [tool.poetry] 36 | requires-poetry = ">=2.0" 37 | version = "0.0.0" 38 | 39 | [tool.poetry.requires-plugins] 40 | poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] } 41 | 42 | [tool.poetry.group.dev.dependencies] 43 | bandit = {extras = ["toml"], version = "*"} 44 | black = "*" 45 | cruft = "*" 46 | flake8 = "*" 47 | flake8-bugbear = "*" 48 | flake8-pyproject = "*" 49 | flake8-simplify = "*" 50 | isort = "*" 51 | mypy = "*" 52 | pep8-naming = "*" 53 | poethepoet = "*" 54 | pre-commit = "*" 55 | pytest = "*" 56 | pytest-cov = "*" 57 | pytest-github-actions-annotate-failures = "*" 58 | pytest-sugar = "*" 59 | types-python-dateutil = "*" 60 | types-requests = "*" 61 | responses = "*" 62 | 63 | [tool.poetry-dynamic-versioning] 64 | enable = true 65 | vcs = "git" 66 | style = "semver" 67 | 68 | [tool.poe.tasks.lint] 69 | cmd = "pre-commit run --all-files --show-diff-on-failure" 70 | help = "Check all files" 71 | 72 | [tool.poe.tasks.pytest] 73 | cmd = "pytest" 74 | help = "Run unit tests with pytest" 75 | 76 | [tool.poe.tasks.test] 77 | sequence = ["lint", "pytest"] 78 | help = "Run all tests" 79 | 80 | [tool.bandit] 81 | skips = ["B101"] # assert_used 82 | 83 | [tool.black] 84 | line-length = 79 85 | 86 | [tool.coverage.run] 87 | source = ["jmapc"] 88 | 89 | [tool.coverage.report] 90 | fail_under = 95 91 | show_missing = true 92 | 93 | [tool.cruft] 94 | skip = [".git"] 95 | 96 | [tool.flake8] 97 | exclude = "./.*" 98 | 99 | [tool.isort] 100 | atomic = true 101 | profile = "black" 102 | line_length = 79 103 | 104 | [tool.mypy] 105 | files = [ "tests", "jmapc" ] 106 | mypy_path = "types" 107 | disallow_untyped_defs = true 108 | no_implicit_optional = true 109 | check_untyped_defs = true 110 | warn_return_any = true 111 | show_error_codes = true 112 | warn_unused_ignores = true 113 | 114 | [tool.pytest.ini_options] 115 | addopts = """\ 116 | --cov \ 117 | --cov-append \ 118 | --cov-report term \ 119 | --cov-report xml:.pytest_coverage.xml \ 120 | --junitxml=.pytest_results.xml \ 121 | """ 122 | 123 | # vim: ft=cfg 124 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smkent/jmapc/f8200fa4d2294f9aa83d9389cfe1e059f3628164/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | from collections.abc import Iterable 7 | 8 | import pytest 9 | import responses 10 | 11 | from jmapc import Client 12 | from jmapc.logging import log 13 | 14 | from .data import make_session_response 15 | 16 | pytest.register_assert_rewrite("tests.data", "tests.utils") 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def test_log() -> Iterable[None]: 21 | class UTCFormatter(logging.Formatter): 22 | converter = time.gmtime 23 | 24 | logger = logging.getLogger() 25 | handler = logging.StreamHandler() 26 | formatter = UTCFormatter( 27 | "%(asctime)s %(name)-12s %(levelname)-8s " 28 | "[%(filename)s:%(funcName)s:%(lineno)d] %(message)s" 29 | ) 30 | handler.setFormatter(formatter) 31 | logger.addHandler(handler) 32 | log.setLevel(logging.DEBUG) 33 | yield 34 | 35 | 36 | @pytest.fixture 37 | def client() -> Iterable[Client]: 38 | yield Client(host="jmap-example.localhost", auth=("ness", "pk_fire")) 39 | 40 | 41 | @pytest.fixture 42 | def http_responses_base() -> Iterable[responses.RequestsMock]: 43 | with responses.RequestsMock() as resp_mock: 44 | yield resp_mock 45 | 46 | 47 | @pytest.fixture 48 | def http_responses( 49 | http_responses_base: responses.RequestsMock, 50 | ) -> Iterable[responses.RequestsMock]: 51 | http_responses_base.add( 52 | method=responses.GET, 53 | url="https://jmap-example.localhost/.well-known/jmap", 54 | body=json.dumps(make_session_response()), 55 | ) 56 | yield http_responses_base 57 | 58 | 59 | @pytest.fixture 60 | def tempdir() -> Iterable[Path]: 61 | with tempfile.TemporaryDirectory(suffix=".unit_test") as td: 62 | yield Path(td) 63 | -------------------------------------------------------------------------------- /tests/data.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def make_session_response() -> dict[str, Any]: 5 | return { 6 | "apiUrl": "https://jmap-api.localhost/api", 7 | "downloadUrl": ( 8 | "https://jmap-api.localhost/jmap/download" 9 | "/{accountId}/{blobId}/{name}?type={type}" 10 | ), 11 | "uploadUrl": "https://jmap-api.localhost/jmap/upload/{accountId}/", 12 | "eventSourceUrl": ( 13 | "https://jmap-api.localhost/events/{types}/{closeafter}/{ping}" 14 | ), 15 | "username": "ness@onett.example.net", 16 | "capabilities": { 17 | "urn:ietf:params:jmap:core": { 18 | "maxSizeUpload": 50_000_000, 19 | "maxConcurrentUpload": 4, 20 | "maxSizeRequest": 10_000_000, 21 | "maxConcurrentRequests": 4, 22 | "maxCallsInRequest": 16, 23 | "maxObjectsInGet": 500, 24 | "maxObjectsInSet": 500, 25 | "collationAlgorithms": [ 26 | "i;ascii-numeric", 27 | "i;ascii-casemap", 28 | "i;octet", 29 | ], 30 | }, 31 | }, 32 | "primaryAccounts": { 33 | "urn:ietf:params:jmap:core": "u1138", 34 | "urn:ietf:params:jmap:mail": "u1138", 35 | "urn:ietf:params:jmap:submission": "u1138", 36 | }, 37 | "state": "test;session;state", 38 | } 39 | -------------------------------------------------------------------------------- /tests/methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smkent/jmapc/f8200fa4d2294f9aa83d9389cfe1e059f3628164/tests/methods/__init__.py -------------------------------------------------------------------------------- /tests/methods/test_base.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from jmapc.methods import Response 6 | 7 | 8 | def test_method_base_get_method_name() -> None: 9 | @dataclass 10 | class TestResponseModel(Response): 11 | method_namespace = "Test" 12 | method_type = "echo" 13 | 14 | assert TestResponseModel.get_method_name() == "Test/echo" 15 | 16 | 17 | def test_method_base_get_method_name_no_method_type() -> None: 18 | @dataclass 19 | class TestResponseModel(Response): 20 | method_namespace = "Test" 21 | 22 | with pytest.raises(ValueError): 23 | TestResponseModel.get_method_name() 24 | 25 | 26 | def test_method_base_get_method_name_no_method_namespace() -> None: 27 | @dataclass 28 | class TestResponseModel(Response): 29 | method_type = "echo" 30 | 31 | with pytest.raises(ValueError): 32 | TestResponseModel.get_method_name() 33 | -------------------------------------------------------------------------------- /tests/methods/test_core.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from jmapc import Client 4 | from jmapc.methods import CoreEcho, CoreEchoResponse 5 | 6 | from ..utils import expect_jmap_call 7 | 8 | 9 | def test_core_echo( 10 | client: Client, http_responses: responses.RequestsMock 11 | ) -> None: 12 | test_data = dict(param1="yes", another_param="ok") 13 | expected_request = { 14 | "methodCalls": [ 15 | [ 16 | "Core/echo", 17 | test_data, 18 | "single.Core/echo", 19 | ], 20 | ], 21 | "using": ["urn:ietf:params:jmap:core"], 22 | } 23 | response = { 24 | "methodResponses": [ 25 | [ 26 | "Core/echo", 27 | test_data, 28 | "single.Core/echo", 29 | ], 30 | ], 31 | } 32 | expect_jmap_call(http_responses, expected_request, response) 33 | echo = CoreEcho(data=test_data) 34 | assert echo.to_dict() == test_data 35 | resp = client.request(echo) 36 | assert resp == CoreEchoResponse(data=test_data) 37 | -------------------------------------------------------------------------------- /tests/methods/test_custom.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from jmapc import Client, Mailbox, Ref, constants 4 | from jmapc.methods import ( 5 | CustomMethod, 6 | CustomResponse, 7 | InvocationResponseOrError, 8 | MailboxGet, 9 | MailboxGetResponse, 10 | ) 11 | 12 | from ..utils import expect_jmap_call 13 | 14 | 15 | def test_custom_method( 16 | client: Client, http_responses: responses.RequestsMock 17 | ) -> None: 18 | test_data = dict( 19 | accountId="u1138", 20 | custom_value="Spiteful Crow", 21 | list_value=["ness", "paula", "jeff", "poo"], 22 | ) 23 | expected_request = { 24 | "methodCalls": [ 25 | [ 26 | "Custom/method", 27 | test_data, 28 | "single.Custom/method", 29 | ], 30 | ], 31 | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 32 | } 33 | response = { 34 | "methodResponses": [ 35 | [ 36 | "Custom/method", 37 | test_data, 38 | "single.Custom/method", 39 | ], 40 | ], 41 | } 42 | expect_jmap_call(http_responses, expected_request, response) 43 | method = CustomMethod(data=test_data) 44 | method.jmap_method = "Custom/method" 45 | method.using = {constants.JMAP_URN_MAIL} 46 | assert method.to_dict() == test_data 47 | resp = client.request(method) 48 | assert resp == CustomResponse(account_id="u1138", data=test_data) 49 | 50 | 51 | def test_custom_method_as_result_reference_target( 52 | client: Client, http_responses: responses.RequestsMock 53 | ) -> None: 54 | test_data = dict( 55 | accountId="u1138", 56 | custom_value="Spiteful Crow", 57 | list_value=["ness", "paula", "jeff", "poo"], 58 | ) 59 | expected_request = { 60 | "methodCalls": [ 61 | [ 62 | "Custom/method", 63 | test_data, 64 | "0.Custom/method", 65 | ], 66 | [ 67 | "Mailbox/get", 68 | { 69 | "accountId": "u1138", 70 | "#ids": { 71 | "name": "Custom/method", 72 | "path": "/example", 73 | "resultOf": "0.Custom/method", 74 | }, 75 | }, 76 | "1.Mailbox/get", 77 | ], 78 | ], 79 | "using": [ 80 | "urn:ietf:params:jmap:core", 81 | "urn:ietf:params:jmap:mail", 82 | ], 83 | } 84 | response = { 85 | "methodResponses": [ 86 | [ 87 | "Custom/method", 88 | test_data, 89 | "0.Custom/method", 90 | ], 91 | [ 92 | "Mailbox/get", 93 | { 94 | "accountId": "u1138", 95 | "list": [ 96 | { 97 | "id": "MBX1", 98 | "name": "First", 99 | "sortOrder": 1, 100 | "totalEmails": 100, 101 | "unreadEmails": 3, 102 | "totalThreads": 5, 103 | "unreadThreads": 1, 104 | "isSubscribed": True, 105 | }, 106 | ], 107 | "not_found": [], 108 | "state": "2187", 109 | }, 110 | "1.Mailbox/get", 111 | ], 112 | ], 113 | } 114 | expect_jmap_call(http_responses, expected_request, response) 115 | method = CustomMethod(data=test_data) 116 | method.jmap_method = "Custom/method" 117 | method.using = {constants.JMAP_URN_MAIL} 118 | assert method.to_dict() == test_data 119 | resp = client.request([method, MailboxGet(ids=Ref("/example"))]) 120 | assert resp == [ 121 | InvocationResponseOrError( 122 | response=CustomResponse(account_id="u1138", data=test_data), 123 | id="0.Custom/method", 124 | ), 125 | InvocationResponseOrError( 126 | response=MailboxGetResponse( 127 | account_id="u1138", 128 | state="2187", 129 | not_found=[], 130 | data=[ 131 | Mailbox( 132 | id="MBX1", 133 | name="First", 134 | sort_order=1, 135 | total_emails=100, 136 | unread_emails=3, 137 | total_threads=5, 138 | unread_threads=1, 139 | is_subscribed=True, 140 | ), 141 | ], 142 | ), 143 | id="1.Mailbox/get", 144 | ), 145 | ] 146 | -------------------------------------------------------------------------------- /tests/methods/test_email.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import responses 4 | 5 | from jmapc import ( 6 | AddedItem, 7 | Client, 8 | Comparator, 9 | Email, 10 | EmailAddress, 11 | EmailBodyPart, 12 | EmailBodyValue, 13 | EmailHeader, 14 | EmailQueryFilterCondition, 15 | ) 16 | from jmapc.methods import ( 17 | EmailChanges, 18 | EmailChangesResponse, 19 | EmailCopy, 20 | EmailCopyResponse, 21 | EmailGet, 22 | EmailGetResponse, 23 | EmailQuery, 24 | EmailQueryChanges, 25 | EmailQueryChangesResponse, 26 | EmailQueryResponse, 27 | EmailSet, 28 | EmailSetResponse, 29 | ) 30 | 31 | from ..utils import expect_jmap_call 32 | 33 | 34 | def test_email_changes( 35 | client: Client, http_responses: responses.RequestsMock 36 | ) -> None: 37 | expected_request = { 38 | "methodCalls": [ 39 | [ 40 | "Email/changes", 41 | { 42 | "accountId": "u1138", 43 | "sinceState": "2999", 44 | "maxChanges": 47, 45 | }, 46 | "single.Email/changes", 47 | ] 48 | ], 49 | "using": [ 50 | "urn:ietf:params:jmap:core", 51 | "urn:ietf:params:jmap:mail", 52 | ], 53 | } 54 | response = { 55 | "methodResponses": [ 56 | [ 57 | "Email/changes", 58 | { 59 | "accountId": "u1138", 60 | "oldState": "2999", 61 | "newState": "3000", 62 | "hasMoreChanges": False, 63 | "created": ["f0001", "f0002"], 64 | "updated": [], 65 | "destroyed": ["f0003"], 66 | }, 67 | "single.Email/changes", 68 | ] 69 | ] 70 | } 71 | expect_jmap_call(http_responses, expected_request, response) 72 | assert client.request( 73 | EmailChanges(since_state="2999", max_changes=47) 74 | ) == EmailChangesResponse( 75 | account_id="u1138", 76 | old_state="2999", 77 | new_state="3000", 78 | has_more_changes=False, 79 | created=["f0001", "f0002"], 80 | updated=[], 81 | destroyed=["f0003"], 82 | ) 83 | 84 | 85 | def test_email_copy( 86 | client: Client, http_responses: responses.RequestsMock 87 | ) -> None: 88 | expected_request = { 89 | "methodCalls": [ 90 | [ 91 | "Email/copy", 92 | { 93 | "fromAccountId": "u2187", 94 | "ifFromInState": "1000", 95 | "ifInState": "2000", 96 | "accountId": "u1138", 97 | "create": { 98 | "M1001": { 99 | "id": "M1001", 100 | } 101 | }, 102 | "onSuccessDestroyOriginal": False, 103 | "destroyFromIfInState": "1001", 104 | }, 105 | "single.Email/copy", 106 | ] 107 | ], 108 | "using": [ 109 | "urn:ietf:params:jmap:core", 110 | "urn:ietf:params:jmap:mail", 111 | ], 112 | } 113 | response = { 114 | "methodResponses": [ 115 | [ 116 | "Email/copy", 117 | { 118 | "fromAccountId": "u2187", 119 | "accountId": "u1138", 120 | "created": { 121 | "M1002": { 122 | "id": "M1002", 123 | } 124 | }, 125 | "oldState": "1", 126 | "newState": "2", 127 | "notCreated": None, 128 | }, 129 | "single.Email/copy", 130 | ] 131 | ] 132 | } 133 | expect_jmap_call(http_responses, expected_request, response) 134 | assert client.request( 135 | EmailCopy( 136 | from_account_id="u2187", 137 | if_from_in_state="1000", 138 | if_in_state="2000", 139 | create={"M1001": Email(id="M1001")}, 140 | destroy_from_if_in_state="1001", 141 | ) 142 | ) == EmailCopyResponse( 143 | account_id="u1138", 144 | from_account_id="u2187", 145 | old_state="1", 146 | new_state="2", 147 | created={"M1002": Email(id="M1002")}, 148 | not_created=None, 149 | ) 150 | 151 | 152 | def test_email_get( 153 | client: Client, http_responses: responses.RequestsMock 154 | ) -> None: 155 | expected_request = { 156 | "methodCalls": [ 157 | [ 158 | "Email/get", 159 | { 160 | "accountId": "u1138", 161 | "ids": ["f0001", "f1000"], 162 | "fetchTextBodyValues": False, 163 | "fetchHTMLBodyValues": False, 164 | "fetchAllBodyValues": False, 165 | "maxBodyValueBytes": 42, 166 | }, 167 | "single.Email/get", 168 | ] 169 | ], 170 | "using": [ 171 | "urn:ietf:params:jmap:core", 172 | "urn:ietf:params:jmap:mail", 173 | ], 174 | } 175 | response = { 176 | "methodResponses": [ 177 | [ 178 | "Email/get", 179 | { 180 | "accountId": "u1138", 181 | "list": [ 182 | { 183 | "id": "f0001", 184 | "threadId": "T1", 185 | "mailboxIds": { 186 | "MBX1": True, 187 | "MBX5": True, 188 | }, 189 | "from": [ 190 | { 191 | "name": "Paula", 192 | "email": "paula@twoson.example.net", 193 | } 194 | ], 195 | "reply_to": [ 196 | { 197 | "name": "Paula", 198 | "email": "paula-reply@twoson.example.net", 199 | } 200 | ], 201 | "subject": ( 202 | "I'm taking a day trip to Happy Happy Village" 203 | ), 204 | "receivedAt": "1994-08-24T12:01:02Z", 205 | }, 206 | ], 207 | "not_found": [], 208 | "state": "2187", 209 | }, 210 | "single.Email/get", 211 | ] 212 | ] 213 | } 214 | expect_jmap_call(http_responses, expected_request, response) 215 | assert client.request( 216 | EmailGet( 217 | ids=["f0001", "f1000"], 218 | fetch_text_body_values=False, 219 | fetch_html_body_values=False, 220 | fetch_all_body_values=False, 221 | max_body_value_bytes=42, 222 | ) 223 | ) == EmailGetResponse( 224 | account_id="u1138", 225 | state="2187", 226 | not_found=[], 227 | data=[ 228 | Email( 229 | id="f0001", 230 | thread_id="T1", 231 | mailbox_ids={"MBX1": True, "MBX5": True}, 232 | mail_from=[ 233 | EmailAddress( 234 | name="Paula", email="paula@twoson.example.net" 235 | ), 236 | ], 237 | reply_to=[ 238 | EmailAddress( 239 | name="Paula", email="paula-reply@twoson.example.net" 240 | ), 241 | ], 242 | subject="I'm taking a day trip to Happy Happy Village", 243 | received_at=datetime( 244 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 245 | ), 246 | ), 247 | ], 248 | ) 249 | 250 | 251 | def test_email_query( 252 | client: Client, http_responses: responses.RequestsMock 253 | ) -> None: 254 | expected_request = { 255 | "methodCalls": [ 256 | [ 257 | "Email/query", 258 | { 259 | "accountId": "u1138", 260 | "collapseThreads": True, 261 | "filter": { 262 | "after": "1994-08-24T12:01:02Z", 263 | "inMailbox": "MBX1", 264 | }, 265 | "limit": 10, 266 | "sort": [ 267 | { 268 | "anchorOffset": 0, 269 | "calculateTotal": False, 270 | "isAscending": False, 271 | "position": 0, 272 | "property": "receivedAt", 273 | } 274 | ], 275 | }, 276 | "single.Email/query", 277 | ] 278 | ], 279 | "using": [ 280 | "urn:ietf:params:jmap:core", 281 | "urn:ietf:params:jmap:mail", 282 | ], 283 | } 284 | response = { 285 | "methodResponses": [ 286 | [ 287 | "Email/query", 288 | { 289 | "accountId": "u1138", 290 | "ids": ["M1000", "M1234"], 291 | "queryState": "5000", 292 | "canCalculateChanges": True, 293 | "position": 42, 294 | "total": 9001, 295 | "limit": 256, 296 | }, 297 | "single.Email/query", 298 | ] 299 | ] 300 | } 301 | expect_jmap_call(http_responses, expected_request, response) 302 | assert client.request( 303 | EmailQuery( 304 | collapse_threads=True, 305 | filter=EmailQueryFilterCondition( 306 | in_mailbox="MBX1", 307 | after=datetime(1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc), 308 | ), 309 | sort=[Comparator(property="receivedAt", is_ascending=False)], 310 | limit=10, 311 | ) 312 | ) == EmailQueryResponse( 313 | account_id="u1138", 314 | ids=["M1000", "M1234"], 315 | query_state="5000", 316 | can_calculate_changes=True, 317 | position=42, 318 | total=9001, 319 | limit=256, 320 | ) 321 | 322 | 323 | def test_email_query_changes( 324 | client: Client, http_responses: responses.RequestsMock 325 | ) -> None: 326 | expected_request = { 327 | "methodCalls": [ 328 | [ 329 | "Email/queryChanges", 330 | { 331 | "accountId": "u1138", 332 | "filter": { 333 | "inMailbox": "MBX1", 334 | "after": "1994-08-24T12:01:02Z", 335 | }, 336 | "sort": [ 337 | { 338 | "anchorOffset": 0, 339 | "calculateTotal": False, 340 | "isAscending": False, 341 | "position": 0, 342 | "property": "receivedAt", 343 | } 344 | ], 345 | "sinceQueryState": "1000", 346 | "calculateTotal": False, 347 | }, 348 | "single.Email/queryChanges", 349 | ] 350 | ], 351 | "using": [ 352 | "urn:ietf:params:jmap:core", 353 | "urn:ietf:params:jmap:mail", 354 | ], 355 | } 356 | response = { 357 | "methodResponses": [ 358 | [ 359 | "Email/queryChanges", 360 | { 361 | "accountId": "u1138", 362 | "oldQueryState": "1000", 363 | "newQueryState": "1003", 364 | "added": [ 365 | { 366 | "id": "M8002", 367 | "index": 3, 368 | }, 369 | { 370 | "id": "M8003", 371 | "index": 8, 372 | }, 373 | ], 374 | "removed": ["M8001"], 375 | "total": 42, 376 | }, 377 | "single.Email/queryChanges", 378 | ] 379 | ] 380 | } 381 | expect_jmap_call(http_responses, expected_request, response) 382 | assert client.request( 383 | EmailQueryChanges( 384 | filter=EmailQueryFilterCondition( 385 | in_mailbox="MBX1", 386 | after=datetime(1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc), 387 | ), 388 | sort=[Comparator(property="receivedAt", is_ascending=False)], 389 | since_query_state="1000", 390 | ) 391 | ) == EmailQueryChangesResponse( 392 | account_id="u1138", 393 | old_query_state="1000", 394 | new_query_state="1003", 395 | removed=["M8001"], 396 | added=[ 397 | AddedItem(id="M8002", index=3), 398 | AddedItem(id="M8003", index=8), 399 | ], 400 | total=42, 401 | ) 402 | 403 | 404 | def test_email_set( 405 | client: Client, http_responses: responses.RequestsMock 406 | ) -> None: 407 | draft = Email( 408 | mail_from=[ 409 | EmailAddress(name="Paula", email="paula@twoson.example.net"), 410 | ], 411 | to=[ 412 | EmailAddress(name="Ness", email="ness@onett.example.net"), 413 | ], 414 | subject="I'm taking a day trip to Happy Happy Village", 415 | keywords={"$draft": True}, 416 | mailbox_ids={"MBX1": True}, 417 | body_values=dict(body=EmailBodyValue(value="See you there!")), 418 | text_body=[EmailBodyPart(part_id="body", type="text/plain")], 419 | headers=[EmailHeader(name="X-Onett-Sanctuary", value="Giant Step")], 420 | ) 421 | 422 | expected_request = { 423 | "methodCalls": [ 424 | [ 425 | "Email/set", 426 | { 427 | "accountId": "u1138", 428 | "create": { 429 | "draft": { 430 | "from": [ 431 | { 432 | "email": "paula@twoson.example.net", 433 | "name": "Paula", 434 | } 435 | ], 436 | "to": [ 437 | { 438 | "email": "ness@onett.example.net", 439 | "name": "Ness", 440 | } 441 | ], 442 | "subject": ( 443 | "I'm taking a day trip to " 444 | "Happy Happy Village" 445 | ), 446 | "keywords": {"$draft": True}, 447 | "mailboxIds": {"MBX1": True}, 448 | "bodyValues": { 449 | "body": {"value": "See you there!"} 450 | }, 451 | "textBody": [ 452 | {"partId": "body", "type": "text/plain"} 453 | ], 454 | "header:X-Onett-Sanctuary": "Giant Step", 455 | } 456 | }, 457 | }, 458 | "single.Email/set", 459 | ] 460 | ], 461 | "using": [ 462 | "urn:ietf:params:jmap:core", 463 | "urn:ietf:params:jmap:mail", 464 | ], 465 | } 466 | response = { 467 | "methodResponses": [ 468 | [ 469 | "Email/set", 470 | { 471 | "accountId": "u1138", 472 | "created": { 473 | "draft": { 474 | "blobId": "G12345", 475 | "id": "M1001", 476 | "size": 42, 477 | "threadId": "T1002", 478 | } 479 | }, 480 | "destroyed": None, 481 | "newState": "2", 482 | "notCreated": None, 483 | "notDestroyed": None, 484 | "notUpdated": None, 485 | "oldState": "1", 486 | "updated": None, 487 | }, 488 | "single.Email/set", 489 | ] 490 | ] 491 | } 492 | expect_jmap_call(http_responses, expected_request, response) 493 | 494 | assert client.request( 495 | EmailSet(create=dict(draft=draft)) 496 | ) == EmailSetResponse( 497 | account_id="u1138", 498 | old_state="1", 499 | new_state="2", 500 | created=dict( 501 | draft=Email( 502 | blob_id="G12345", id="M1001", size=42, thread_id="T1002" 503 | ) 504 | ), 505 | updated=None, 506 | destroyed=None, 507 | not_created=None, 508 | not_updated=None, 509 | not_destroyed=None, 510 | ) 511 | -------------------------------------------------------------------------------- /tests/methods/test_email_submission.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import responses 4 | 5 | from jmapc import ( 6 | AddedItem, 7 | Address, 8 | Client, 9 | EmailSubmission, 10 | EmailSubmissionQueryFilterCondition, 11 | Envelope, 12 | SetError, 13 | UndoStatus, 14 | ) 15 | from jmapc.methods import ( 16 | EmailSetResponse, 17 | EmailSubmissionChanges, 18 | EmailSubmissionChangesResponse, 19 | EmailSubmissionGet, 20 | EmailSubmissionGetResponse, 21 | EmailSubmissionQuery, 22 | EmailSubmissionQueryChanges, 23 | EmailSubmissionQueryChangesResponse, 24 | EmailSubmissionQueryResponse, 25 | EmailSubmissionSet, 26 | EmailSubmissionSetResponse, 27 | ) 28 | 29 | from ..utils import expect_jmap_call 30 | 31 | expected_request_create = { 32 | "emailToSend": { 33 | "emailId": "#draft", 34 | "identityId": "1000", 35 | "envelope": { 36 | "mailFrom": { 37 | "email": "ness@onett.example.com", 38 | "parameters": None, 39 | }, 40 | "rcptTo": [ 41 | { 42 | "email": "ness@onett.example.com", 43 | "parameters": None, 44 | } 45 | ], 46 | }, 47 | } 48 | } 49 | email_submission_set_response = { 50 | "accountId": "u1138", 51 | "created": { 52 | "emailToSend": { 53 | "id": "S2000", 54 | "sendAt": "1994-08-24T12:01:02Z", 55 | "undoStatus": "final", 56 | } 57 | }, 58 | "updated": None, 59 | "destroyed": None, 60 | "oldState": "1", 61 | "newState": "2", 62 | "notCreated": None, 63 | "notUpdated": None, 64 | "notDestroyed": None, 65 | } 66 | 67 | 68 | def test_email_submission_changes( 69 | client: Client, http_responses: responses.RequestsMock 70 | ) -> None: 71 | expected_request = { 72 | "methodCalls": [ 73 | [ 74 | "EmailSubmission/changes", 75 | { 76 | "accountId": "u1138", 77 | "sinceState": "2999", 78 | "maxChanges": 47, 79 | }, 80 | "single.EmailSubmission/changes", 81 | ] 82 | ], 83 | "using": [ 84 | "urn:ietf:params:jmap:core", 85 | "urn:ietf:params:jmap:submission", 86 | ], 87 | } 88 | response = { 89 | "methodResponses": [ 90 | [ 91 | "EmailSubmission/changes", 92 | { 93 | "accountId": "u1138", 94 | "oldState": "2999", 95 | "newState": "3000", 96 | "hasMoreChanges": False, 97 | "created": ["S0001", "S0002"], 98 | "updated": [], 99 | "destroyed": ["S0003"], 100 | }, 101 | "single.EmailSubmission/changes", 102 | ] 103 | ] 104 | } 105 | expect_jmap_call(http_responses, expected_request, response) 106 | assert client.request( 107 | EmailSubmissionChanges(since_state="2999", max_changes=47) 108 | ) == EmailSubmissionChangesResponse( 109 | account_id="u1138", 110 | old_state="2999", 111 | new_state="3000", 112 | has_more_changes=False, 113 | created=["S0001", "S0002"], 114 | updated=[], 115 | destroyed=["S0003"], 116 | ) 117 | 118 | 119 | def test_email_submission_get( 120 | client: Client, http_responses: responses.RequestsMock 121 | ) -> None: 122 | expected_request = { 123 | "methodCalls": [ 124 | [ 125 | "EmailSubmission/get", 126 | { 127 | "accountId": "u1138", 128 | "ids": ["S2000"], 129 | }, 130 | "single.EmailSubmission/get", 131 | ] 132 | ], 133 | "using": [ 134 | "urn:ietf:params:jmap:core", 135 | "urn:ietf:params:jmap:submission", 136 | ], 137 | } 138 | response = { 139 | "methodResponses": [ 140 | [ 141 | "EmailSubmission/get", 142 | { 143 | "accountId": "u1138", 144 | "state": "2187", 145 | "notFound": [], 146 | "list": [ 147 | { 148 | "id": "S2000", 149 | "undoStatus": "final", 150 | "sendAt": "1994-08-24T12:01:02Z", 151 | } 152 | ], 153 | }, 154 | "single.EmailSubmission/get", 155 | ] 156 | ] 157 | } 158 | expect_jmap_call(http_responses, expected_request, response) 159 | assert client.request( 160 | EmailSubmissionGet(ids=["S2000"]) 161 | ) == EmailSubmissionGetResponse( 162 | account_id="u1138", 163 | state="2187", 164 | not_found=[], 165 | data=[ 166 | EmailSubmission( 167 | id="S2000", 168 | undo_status=UndoStatus.FINAL, 169 | send_at=datetime(1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc), 170 | ), 171 | ], 172 | ) 173 | 174 | 175 | def test_email_submission_query( 176 | client: Client, http_responses: responses.RequestsMock 177 | ) -> None: 178 | expected_request = { 179 | "methodCalls": [ 180 | [ 181 | "EmailSubmission/query", 182 | { 183 | "accountId": "u1138", 184 | "filter": { 185 | "undoStatus": "final", 186 | }, 187 | }, 188 | "single.EmailSubmission/query", 189 | ] 190 | ], 191 | "using": [ 192 | "urn:ietf:params:jmap:core", 193 | "urn:ietf:params:jmap:submission", 194 | ], 195 | } 196 | response = { 197 | "methodResponses": [ 198 | [ 199 | "EmailSubmission/query", 200 | { 201 | "accountId": "u1138", 202 | "ids": ["S2000", "S2001"], 203 | "queryState": "4000", 204 | "canCalculateChanges": True, 205 | "position": 42, 206 | "total": 9001, 207 | "limit": 256, 208 | }, 209 | "single.EmailSubmission/query", 210 | ] 211 | ] 212 | } 213 | expect_jmap_call(http_responses, expected_request, response) 214 | assert client.request( 215 | EmailSubmissionQuery( 216 | filter=EmailSubmissionQueryFilterCondition( 217 | undo_status=UndoStatus.FINAL, 218 | ) 219 | ) 220 | ) == EmailSubmissionQueryResponse( 221 | account_id="u1138", 222 | ids=["S2000", "S2001"], 223 | query_state="4000", 224 | can_calculate_changes=True, 225 | position=42, 226 | total=9001, 227 | limit=256, 228 | ) 229 | 230 | 231 | def test_email_submission_query_changes( 232 | client: Client, http_responses: responses.RequestsMock 233 | ) -> None: 234 | expected_request = { 235 | "methodCalls": [ 236 | [ 237 | "EmailSubmission/queryChanges", 238 | { 239 | "accountId": "u1138", 240 | "filter": { 241 | "undoStatus": "final", 242 | }, 243 | "sinceQueryState": "1000", 244 | "calculateTotal": False, 245 | }, 246 | "single.EmailSubmission/queryChanges", 247 | ] 248 | ], 249 | "using": [ 250 | "urn:ietf:params:jmap:core", 251 | "urn:ietf:params:jmap:submission", 252 | ], 253 | } 254 | response = { 255 | "methodResponses": [ 256 | [ 257 | "EmailSubmission/queryChanges", 258 | { 259 | "accountId": "u1138", 260 | "oldQueryState": "1000", 261 | "newQueryState": "1003", 262 | "added": [ 263 | { 264 | "id": "S2000", 265 | "index": 3, 266 | }, 267 | { 268 | "id": "S2001", 269 | "index": 8, 270 | }, 271 | ], 272 | "removed": ["S2008"], 273 | "total": 42, 274 | }, 275 | "single.EmailSubmission/queryChanges", 276 | ] 277 | ] 278 | } 279 | expect_jmap_call(http_responses, expected_request, response) 280 | assert client.request( 281 | EmailSubmissionQueryChanges( 282 | filter=EmailSubmissionQueryFilterCondition( 283 | undo_status=UndoStatus.FINAL 284 | ), 285 | since_query_state="1000", 286 | ) 287 | ) == EmailSubmissionQueryChangesResponse( 288 | account_id="u1138", 289 | old_query_state="1000", 290 | new_query_state="1003", 291 | removed=["S2008"], 292 | added=[ 293 | AddedItem(id="S2000", index=3), 294 | AddedItem(id="S2001", index=8), 295 | ], 296 | total=42, 297 | ) 298 | 299 | 300 | def test_email_submission_set( 301 | client: Client, http_responses: responses.RequestsMock 302 | ) -> None: 303 | expected_request = { 304 | "methodCalls": [ 305 | [ 306 | "EmailSubmission/set", 307 | { 308 | "accountId": "u1138", 309 | "create": expected_request_create, 310 | }, 311 | "single.EmailSubmission/set", 312 | ] 313 | ], 314 | "using": [ 315 | "urn:ietf:params:jmap:core", 316 | "urn:ietf:params:jmap:submission", 317 | ], 318 | } 319 | response = { 320 | "methodResponses": [ 321 | [ 322 | "EmailSubmission/set", 323 | email_submission_set_response, 324 | "single.EmailSubmission/set", 325 | ] 326 | ] 327 | } 328 | expect_jmap_call(http_responses, expected_request, response) 329 | assert client.request( 330 | EmailSubmissionSet( 331 | create=dict( 332 | emailToSend=EmailSubmission( 333 | email_id="#draft", 334 | identity_id="1000", 335 | envelope=Envelope( 336 | mail_from=Address(email="ness@onett.example.com"), 337 | rcpt_to=[Address(email="ness@onett.example.com")], 338 | ), 339 | ) 340 | ) 341 | ) 342 | ) == EmailSubmissionSetResponse( 343 | account_id="u1138", 344 | old_state="1", 345 | new_state="2", 346 | created=dict( 347 | emailToSend=EmailSubmission( 348 | id="S2000", 349 | undo_status=UndoStatus.FINAL, 350 | send_at=datetime(1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc), 351 | ), 352 | ), 353 | updated=None, 354 | destroyed=None, 355 | not_created=None, 356 | not_updated=None, 357 | not_destroyed=None, 358 | ) 359 | 360 | 361 | def test_email_submission_set_on_success_destroy_email( 362 | client: Client, http_responses: responses.RequestsMock 363 | ) -> None: 364 | expected_request = { 365 | "methodCalls": [ 366 | [ 367 | "EmailSubmission/set", 368 | { 369 | "accountId": "u1138", 370 | "create": expected_request_create, 371 | "onSuccessDestroyEmail": ["#emailToSend"], 372 | }, 373 | "single.EmailSubmission/set", 374 | ] 375 | ], 376 | "using": [ 377 | "urn:ietf:params:jmap:core", 378 | "urn:ietf:params:jmap:submission", 379 | ], 380 | } 381 | response = { 382 | "methodResponses": [ 383 | [ 384 | "EmailSubmission/set", 385 | email_submission_set_response, 386 | "single.EmailSubmission/set", 387 | ], 388 | [ 389 | "Email/set", 390 | { 391 | "accountId": "u1138", 392 | "oldState": "2", 393 | "newState": "3", 394 | "created": None, 395 | "updated": None, 396 | "destroyed": ["Mdeadbeefdeadbeefdeadbeef"], 397 | "notCreated": None, 398 | "notUpdated": None, 399 | "notDestroyed": None, 400 | }, 401 | "single.EmailSubmission/set", 402 | ], 403 | ] 404 | } 405 | expect_jmap_call(http_responses, expected_request, response) 406 | assert client.request( 407 | EmailSubmissionSet( 408 | on_success_destroy_email=["#emailToSend"], 409 | create=dict( 410 | emailToSend=EmailSubmission( 411 | email_id="#draft", 412 | identity_id="1000", 413 | envelope=Envelope( 414 | mail_from=Address(email="ness@onett.example.com"), 415 | rcpt_to=[Address(email="ness@onett.example.com")], 416 | ), 417 | ) 418 | ), 419 | ) 420 | ) == [ 421 | EmailSubmissionSetResponse( 422 | account_id="u1138", 423 | old_state="1", 424 | new_state="2", 425 | created=dict( 426 | emailToSend=EmailSubmission( 427 | id="S2000", 428 | undo_status=UndoStatus.FINAL, 429 | send_at=datetime( 430 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 431 | ), 432 | ), 433 | ), 434 | updated=None, 435 | destroyed=None, 436 | not_created=None, 437 | not_updated=None, 438 | not_destroyed=None, 439 | ), 440 | EmailSetResponse( 441 | account_id="u1138", 442 | old_state="2", 443 | new_state="3", 444 | created=None, 445 | updated=None, 446 | destroyed=["Mdeadbeefdeadbeefdeadbeef"], 447 | not_created=None, 448 | not_updated=None, 449 | not_destroyed=None, 450 | ), 451 | ] 452 | 453 | 454 | def test_email_submission_set_on_success_update_email( 455 | client: Client, http_responses: responses.RequestsMock 456 | ) -> None: 457 | expected_request = { 458 | "methodCalls": [ 459 | [ 460 | "EmailSubmission/set", 461 | { 462 | "accountId": "u1138", 463 | "create": expected_request_create, 464 | "onSuccessUpdateEmail": { 465 | "keywords/$draft": None, 466 | }, 467 | }, 468 | "single.EmailSubmission/set", 469 | ] 470 | ], 471 | "using": [ 472 | "urn:ietf:params:jmap:core", 473 | "urn:ietf:params:jmap:submission", 474 | ], 475 | } 476 | response = { 477 | "methodResponses": [ 478 | [ 479 | "EmailSubmission/set", 480 | email_submission_set_response, 481 | "single.EmailSubmission/set", 482 | ], 483 | [ 484 | "Email/set", 485 | { 486 | "accountId": "u1138", 487 | "oldState": "2", 488 | "newState": "3", 489 | "created": None, 490 | "updated": { 491 | "Mdeadbeefdeadbeefdeadbeef": None, 492 | }, 493 | "destroyed": None, 494 | "notCreated": None, 495 | "notUpdated": None, 496 | "notDestroyed": None, 497 | }, 498 | "single.EmailSubmission/set", 499 | ], 500 | ] 501 | } 502 | expect_jmap_call(http_responses, expected_request, response) 503 | assert client.request( 504 | EmailSubmissionSet( 505 | on_success_update_email={ 506 | "keywords/$draft": None, 507 | }, 508 | create=dict( 509 | emailToSend=EmailSubmission( 510 | email_id="#draft", 511 | identity_id="1000", 512 | envelope=Envelope( 513 | mail_from=Address(email="ness@onett.example.com"), 514 | rcpt_to=[Address(email="ness@onett.example.com")], 515 | ), 516 | ) 517 | ), 518 | ) 519 | ) == [ 520 | EmailSubmissionSetResponse( 521 | account_id="u1138", 522 | old_state="1", 523 | new_state="2", 524 | created=dict( 525 | emailToSend=EmailSubmission( 526 | id="S2000", 527 | undo_status=UndoStatus.FINAL, 528 | send_at=datetime( 529 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 530 | ), 531 | ), 532 | ), 533 | updated=None, 534 | destroyed=None, 535 | not_created=None, 536 | not_updated=None, 537 | not_destroyed=None, 538 | ), 539 | EmailSetResponse( 540 | account_id="u1138", 541 | old_state="2", 542 | new_state="3", 543 | created=None, 544 | updated={"Mdeadbeefdeadbeefdeadbeef": None}, 545 | destroyed=None, 546 | not_created=None, 547 | not_updated=None, 548 | not_destroyed=None, 549 | ), 550 | ] 551 | 552 | 553 | def test_email_submission_set_update_email_error( 554 | client: Client, http_responses: responses.RequestsMock 555 | ) -> None: 556 | expected_request = { 557 | "methodCalls": [ 558 | [ 559 | "EmailSubmission/set", 560 | { 561 | "accountId": "u1138", 562 | "create": expected_request_create, 563 | "onSuccessUpdateEmail": { 564 | "keywords/$draft": None, 565 | "mailboxIds/MBX5": None, 566 | }, 567 | }, 568 | "single.EmailSubmission/set", 569 | ] 570 | ], 571 | "using": [ 572 | "urn:ietf:params:jmap:core", 573 | "urn:ietf:params:jmap:submission", 574 | ], 575 | } 576 | response = { 577 | "methodResponses": [ 578 | [ 579 | "EmailSubmission/set", 580 | email_submission_set_response, 581 | "single.EmailSubmission/set", 582 | ], 583 | [ 584 | "Email/set", 585 | { 586 | "accountId": "u1138", 587 | "oldState": "2", 588 | "newState": "3", 589 | "created": None, 590 | "updated": None, 591 | "destroyed": None, 592 | "notCreated": None, 593 | "notUpdated": { 594 | "Mdeadbeefdeadbeefdeadbeef": { 595 | "type": "invalidProperties", 596 | "properties": ["mailboxIds"], 597 | }, 598 | }, 599 | "notDestroyed": None, 600 | }, 601 | "single.EmailSubmission/set", 602 | ], 603 | ] 604 | } 605 | expect_jmap_call(http_responses, expected_request, response) 606 | assert client.request( 607 | EmailSubmissionSet( 608 | on_success_update_email={ 609 | "keywords/$draft": None, 610 | "mailboxIds/MBX5": None, 611 | }, 612 | create=dict( 613 | emailToSend=EmailSubmission( 614 | email_id="#draft", 615 | identity_id="1000", 616 | envelope=Envelope( 617 | mail_from=Address(email="ness@onett.example.com"), 618 | rcpt_to=[Address(email="ness@onett.example.com")], 619 | ), 620 | ) 621 | ), 622 | ) 623 | ) == [ 624 | EmailSubmissionSetResponse( 625 | account_id="u1138", 626 | old_state="1", 627 | new_state="2", 628 | created=dict( 629 | emailToSend=EmailSubmission( 630 | id="S2000", 631 | undo_status=UndoStatus.FINAL, 632 | send_at=datetime( 633 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 634 | ), 635 | ), 636 | ), 637 | updated=None, 638 | destroyed=None, 639 | not_created=None, 640 | not_updated=None, 641 | not_destroyed=None, 642 | ), 643 | EmailSetResponse( 644 | account_id="u1138", 645 | old_state="2", 646 | new_state="3", 647 | created=None, 648 | updated=None, 649 | destroyed=None, 650 | not_created=None, 651 | not_updated={ 652 | "Mdeadbeefdeadbeefdeadbeef": SetError( 653 | type="invalidProperties", 654 | description=None, 655 | properties=["mailboxIds"], 656 | ) 657 | }, 658 | not_destroyed=None, 659 | ), 660 | ] 661 | -------------------------------------------------------------------------------- /tests/methods/test_errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | import responses 5 | 6 | from jmapc import Client, ClientError, Error, errors 7 | from jmapc.methods import CoreEcho, InvocationResponseOrError 8 | 9 | from ..utils import expect_jmap_call 10 | 11 | 12 | @pytest.mark.parametrize("raise_errors", [True, False]) 13 | @pytest.mark.parametrize( 14 | ["method_response", "expected_error"], 15 | [ 16 | ( 17 | { 18 | "type": "accountNotFound", 19 | }, 20 | errors.AccountNotFound(), 21 | ), 22 | ( 23 | { 24 | "type": "accountNotSupportedByMethod", 25 | }, 26 | errors.AccountNotSupportedByMethod(), 27 | ), 28 | ( 29 | { 30 | "type": "accountReadOnly", 31 | }, 32 | errors.AccountReadOnly(), 33 | ), 34 | ( 35 | { 36 | "type": "invalidArguments", 37 | "arguments": ["ids"], 38 | }, 39 | errors.InvalidArguments(arguments=["ids"]), 40 | ), 41 | ( 42 | { 43 | "type": "invalidResultReference", 44 | }, 45 | errors.InvalidResultReference(), 46 | ), 47 | ( 48 | { 49 | "type": "forbidden", 50 | }, 51 | errors.Forbidden(), 52 | ), 53 | ( 54 | { 55 | "type": "serverFail", 56 | "description": "Something went wrong", 57 | }, 58 | errors.ServerFail(description="Something went wrong"), 59 | ), 60 | ( 61 | { 62 | "type": "serverPartialFail", 63 | }, 64 | errors.ServerPartialFail(), 65 | ), 66 | ( 67 | { 68 | "type": "serverUnavailable", 69 | }, 70 | errors.ServerUnavailable(), 71 | ), 72 | ( 73 | { 74 | "type": "unknownMethod", 75 | }, 76 | errors.UnknownMethod(), 77 | ), 78 | ( 79 | { 80 | "type": "unsupportedUnitTestErrorType", 81 | "extraField": "This is an unknown error type", 82 | }, 83 | errors.Error(type="unsupportedUnitTestErrorType"), 84 | ), 85 | ], 86 | ) 87 | def test_method_error( 88 | client: Client, 89 | http_responses: responses.RequestsMock, 90 | method_response: dict[str, Any], 91 | expected_error: Error, 92 | raise_errors: bool, 93 | ) -> None: 94 | test_data = dict(param1="yes", another_param="ok") 95 | expected_request = { 96 | "methodCalls": [ 97 | ["Core/echo", test_data, "single.Core/echo"], 98 | ], 99 | "using": ["urn:ietf:params:jmap:core"], 100 | } 101 | response = { 102 | "methodResponses": [ 103 | ["error", method_response, "single.Core/echo"], 104 | ], 105 | } 106 | expect_jmap_call(http_responses, expected_request, response) 107 | if raise_errors: 108 | with pytest.raises(ClientError) as e: 109 | client.request(CoreEcho(data=test_data), raise_errors=True) 110 | assert str(e.value) == "Errors found in method responses" 111 | assert e.value.result == [ 112 | InvocationResponseOrError( 113 | id="single.Core/echo", response=expected_error 114 | ) 115 | ] 116 | else: 117 | resp = client.request(CoreEcho(data=test_data), raise_errors=False) 118 | assert resp == expected_error 119 | -------------------------------------------------------------------------------- /tests/methods/test_fastmail_maskedemail.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import responses 4 | 5 | from jmapc import Client 6 | from jmapc.fastmail import ( 7 | MaskedEmail, 8 | MaskedEmailGet, 9 | MaskedEmailGetResponse, 10 | MaskedEmailSet, 11 | MaskedEmailSetResponse, 12 | MaskedEmailState, 13 | ) 14 | 15 | from ..utils import expect_jmap_call 16 | 17 | 18 | def test_maskedemail_get( 19 | client: Client, http_responses: responses.RequestsMock 20 | ) -> None: 21 | expected_request = { 22 | "methodCalls": [ 23 | [ 24 | "MaskedEmail/get", 25 | {"accountId": "u1138", "ids": ["masked-1138"]}, 26 | "single.MaskedEmail/get", 27 | ] 28 | ], 29 | "using": [ 30 | "https://www.fastmail.com/dev/maskedemail", 31 | "urn:ietf:params:jmap:core", 32 | ], 33 | } 34 | response = { 35 | "methodResponses": [ 36 | [ 37 | "MaskedEmail/get", 38 | { 39 | "accountId": "u1138", 40 | "list": [ 41 | { 42 | "id": "masked-1138", 43 | "email": "pk.fire@ness.example.com", 44 | "forDomain": "ness.example.com", 45 | "description": ( 46 | "Masked Email (pk.fire@ness.example.com)" 47 | ), 48 | "lastMessageAt": "1994-08-24T12:01:02Z", 49 | "createdAt": "1994-08-24T12:01:02Z", 50 | "createdBy": "ness", 51 | "url": None, 52 | }, 53 | ], 54 | "not_found": [], 55 | "state": "2187", 56 | }, 57 | "single.MaskedEmail/get", 58 | ] 59 | ] 60 | } 61 | expect_jmap_call(http_responses, expected_request, response) 62 | jmap_response = client.request(MaskedEmailGet(ids=["masked-1138"])) 63 | assert jmap_response == MaskedEmailGetResponse( 64 | account_id="u1138", 65 | state="2187", 66 | not_found=[], 67 | data=[ 68 | MaskedEmail( 69 | id="masked-1138", 70 | email="pk.fire@ness.example.com", 71 | for_domain="ness.example.com", 72 | description="Masked Email (pk.fire@ness.example.com)", 73 | last_message_at=datetime( 74 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 75 | ), 76 | created_at=datetime( 77 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 78 | ), 79 | created_by="ness", 80 | ), 81 | ], 82 | ) 83 | 84 | 85 | def test_maskedemail_set( 86 | client: Client, http_responses: responses.RequestsMock 87 | ) -> None: 88 | expected_request = { 89 | "methodCalls": [ 90 | [ 91 | "MaskedEmail/set", 92 | { 93 | "accountId": "u1138", 94 | "create": { 95 | "create": { 96 | "email": "pk.fire2187@ness.example.com", 97 | "forDomain": "ness.example.com", 98 | "description": ( 99 | "Masked Email (pk.fire2187@ness.example.com)" 100 | ), 101 | "lastMessageAt": "1994-08-24T12:01:02Z", 102 | "createdAt": "1994-08-24T12:01:02Z", 103 | "createdBy": "API Token: onett-dev", 104 | }, 105 | }, 106 | }, 107 | "single.MaskedEmail/set", 108 | ] 109 | ], 110 | "using": [ 111 | "https://www.fastmail.com/dev/maskedemail", 112 | "urn:ietf:params:jmap:core", 113 | ], 114 | } 115 | response = { 116 | "methodResponses": [ 117 | [ 118 | "MaskedEmail/set", 119 | { 120 | "accountId": "u1138", 121 | "oldState": None, 122 | "newState": None, 123 | "created": { 124 | "create": { 125 | "id": "masked-42", 126 | "url": None, 127 | "state": "pending", 128 | "forDomain": "ness.example.com", 129 | "description": ( 130 | "Masked Email (pk.fire2187@ness.example.com)" 131 | ), 132 | "createdAt": "1994-08-24T12:01:02Z", 133 | "email": "pk.fire2187@ness.example.com", 134 | "createdBy": "API Token: onett-dev", 135 | "lastMessageAt": None, 136 | } 137 | }, 138 | "updated": {}, 139 | "destroyed": [], 140 | }, 141 | "single.MaskedEmail/set", 142 | ] 143 | ] 144 | } 145 | expect_jmap_call(http_responses, expected_request, response) 146 | 147 | assert client.request( 148 | MaskedEmailSet( 149 | create=dict( 150 | create=MaskedEmail( 151 | id=None, 152 | email="pk.fire2187@ness.example.com", 153 | for_domain="ness.example.com", 154 | description="Masked Email (pk.fire2187@ness.example.com)", 155 | last_message_at=datetime( 156 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 157 | ), 158 | created_at=datetime( 159 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 160 | ), 161 | created_by="API Token: onett-dev", 162 | ) 163 | ) 164 | ) 165 | ) == MaskedEmailSetResponse( 166 | account_id="u1138", 167 | old_state=None, 168 | new_state=None, 169 | created=dict( 170 | create=MaskedEmail( 171 | id="masked-42", 172 | email="pk.fire2187@ness.example.com", 173 | state=MaskedEmailState.PENDING, 174 | for_domain="ness.example.com", 175 | description="Masked Email (pk.fire2187@ness.example.com)", 176 | last_message_at=None, 177 | created_at=datetime( 178 | 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc 179 | ), 180 | created_by="API Token: onett-dev", 181 | ), 182 | ), 183 | updated={}, 184 | destroyed=[], 185 | not_created=None, 186 | not_updated=None, 187 | not_destroyed=None, 188 | ) 189 | -------------------------------------------------------------------------------- /tests/methods/test_identity.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from jmapc import Client, Identity 4 | from jmapc.methods import ( 5 | IdentityChanges, 6 | IdentityChangesResponse, 7 | IdentityGet, 8 | IdentityGetResponse, 9 | IdentitySet, 10 | IdentitySetResponse, 11 | ) 12 | 13 | from ..utils import expect_jmap_call 14 | 15 | 16 | def test_identity_changes( 17 | client: Client, http_responses: responses.RequestsMock 18 | ) -> None: 19 | expected_request = { 20 | "methodCalls": [ 21 | [ 22 | "Identity/changes", 23 | { 24 | "accountId": "u1138", 25 | "sinceState": "2999", 26 | "maxChanges": 47, 27 | }, 28 | "single.Identity/changes", 29 | ] 30 | ], 31 | "using": [ 32 | "urn:ietf:params:jmap:core", 33 | "urn:ietf:params:jmap:submission", 34 | ], 35 | } 36 | response = { 37 | "methodResponses": [ 38 | [ 39 | "Identity/changes", 40 | { 41 | "accountId": "u1138", 42 | "oldState": "2999", 43 | "newState": "3000", 44 | "hasMoreChanges": False, 45 | "created": ["0001", "0002"], 46 | "updated": [], 47 | "destroyed": ["0003"], 48 | }, 49 | "single.Identity/changes", 50 | ] 51 | ] 52 | } 53 | expect_jmap_call(http_responses, expected_request, response) 54 | assert client.request( 55 | IdentityChanges(since_state="2999", max_changes=47) 56 | ) == IdentityChangesResponse( 57 | account_id="u1138", 58 | old_state="2999", 59 | new_state="3000", 60 | has_more_changes=False, 61 | created=["0001", "0002"], 62 | updated=[], 63 | destroyed=["0003"], 64 | ) 65 | 66 | 67 | def test_identity_get( 68 | client: Client, http_responses: responses.RequestsMock 69 | ) -> None: 70 | expected_request = { 71 | "methodCalls": [ 72 | ["Identity/get", {"accountId": "u1138"}, "single.Identity/get"] 73 | ], 74 | "using": [ 75 | "urn:ietf:params:jmap:core", 76 | "urn:ietf:params:jmap:submission", 77 | ], 78 | } 79 | response = { 80 | "methodResponses": [ 81 | [ 82 | "Identity/get", 83 | { 84 | "accountId": "u1138", 85 | "list": [ 86 | { 87 | "bcc": None, 88 | "email": "ness@onett.example.net", 89 | "htmlSignature": "", 90 | "id": "0001", 91 | "mayDelete": False, 92 | "name": "Ness", 93 | "replyTo": None, 94 | "textSignature": "", 95 | }, 96 | ], 97 | "not_found": [], 98 | "state": "2187", 99 | }, 100 | "single.Identity/get", 101 | ] 102 | ] 103 | } 104 | expect_jmap_call(http_responses, expected_request, response) 105 | assert client.request(IdentityGet()) == IdentityGetResponse( 106 | account_id="u1138", 107 | state="2187", 108 | not_found=[], 109 | data=[ 110 | Identity( 111 | id="0001", 112 | name="Ness", 113 | email="ness@onett.example.net", 114 | reply_to=None, 115 | bcc=None, 116 | text_signature="", 117 | html_signature="", 118 | may_delete=False, 119 | ) 120 | ], 121 | ) 122 | 123 | 124 | def test_identity_set( 125 | client: Client, http_responses: responses.RequestsMock 126 | ) -> None: 127 | expected_request = { 128 | "methodCalls": [ 129 | [ 130 | "Identity/set", 131 | { 132 | "accountId": "u1138", 133 | "create": { 134 | "new_id": { 135 | "name": "Mr. Saturn", 136 | "email": "mr.saturn@saturn.valley.example.net", 137 | "mayDelete": False, 138 | "textSignature": "Boing", 139 | "htmlSignature": "Boing", 140 | } 141 | }, 142 | }, 143 | "single.Identity/set", 144 | ] 145 | ], 146 | "using": [ 147 | "urn:ietf:params:jmap:core", 148 | "urn:ietf:params:jmap:submission", 149 | ], 150 | } 151 | response = { 152 | "methodResponses": [ 153 | [ 154 | "Identity/set", 155 | { 156 | "accountId": "u1138", 157 | "oldState": "1", 158 | "newState": "2", 159 | "created": { 160 | "new_id": { 161 | "name": "Mr. Saturn", 162 | "email": "mr.saturn@saturn.valley.example.net", 163 | "mayDelete": False, 164 | "textSignature": "Boing", 165 | "htmlSignature": "Boing", 166 | "replyTo": None, 167 | "bcc": None, 168 | "id": "0002", 169 | }, 170 | }, 171 | "updated": None, 172 | "destroyed": None, 173 | "notCreated": None, 174 | "notDestroyed": None, 175 | "notUpdated": None, 176 | }, 177 | "single.Identity/set", 178 | ] 179 | ] 180 | } 181 | expect_jmap_call(http_responses, expected_request, response) 182 | 183 | assert client.request( 184 | IdentitySet( 185 | create=dict( 186 | new_id=Identity( 187 | name="Mr. Saturn", 188 | email="mr.saturn@saturn.valley.example.net", 189 | reply_to=None, 190 | bcc=None, 191 | text_signature="Boing", 192 | html_signature="Boing", 193 | may_delete=False, 194 | ) 195 | ) 196 | ) 197 | ) == IdentitySetResponse( 198 | account_id="u1138", 199 | old_state="1", 200 | new_state="2", 201 | created=dict( 202 | new_id=Identity( 203 | id="0002", 204 | name="Mr. Saturn", 205 | email="mr.saturn@saturn.valley.example.net", 206 | reply_to=None, 207 | bcc=None, 208 | text_signature="Boing", 209 | html_signature="Boing", 210 | may_delete=False, 211 | ) 212 | ), 213 | updated=None, 214 | destroyed=None, 215 | not_created=None, 216 | not_updated=None, 217 | not_destroyed=None, 218 | ) 219 | -------------------------------------------------------------------------------- /tests/methods/test_mailbox.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from jmapc import AddedItem, Client, Mailbox, MailboxQueryFilterCondition 4 | from jmapc.methods import ( 5 | MailboxChanges, 6 | MailboxChangesResponse, 7 | MailboxGet, 8 | MailboxGetResponse, 9 | MailboxQuery, 10 | MailboxQueryChanges, 11 | MailboxQueryChangesResponse, 12 | MailboxQueryResponse, 13 | MailboxSet, 14 | MailboxSetResponse, 15 | ) 16 | 17 | from ..utils import expect_jmap_call 18 | 19 | 20 | def test_mailbox_changes( 21 | client: Client, http_responses: responses.RequestsMock 22 | ) -> None: 23 | expected_request = { 24 | "methodCalls": [ 25 | [ 26 | "Mailbox/changes", 27 | { 28 | "accountId": "u1138", 29 | "sinceState": "2999", 30 | "maxChanges": 47, 31 | }, 32 | "single.Mailbox/changes", 33 | ] 34 | ], 35 | "using": [ 36 | "urn:ietf:params:jmap:core", 37 | "urn:ietf:params:jmap:mail", 38 | ], 39 | } 40 | response = { 41 | "methodResponses": [ 42 | [ 43 | "Mailbox/changes", 44 | { 45 | "accountId": "u1138", 46 | "oldState": "2999", 47 | "newState": "3000", 48 | "hasMoreChanges": False, 49 | "created": ["MBX0001", "MBX0002"], 50 | "updated": [], 51 | "destroyed": ["MBX0003"], 52 | }, 53 | "single.Mailbox/changes", 54 | ] 55 | ] 56 | } 57 | expect_jmap_call(http_responses, expected_request, response) 58 | assert client.request( 59 | MailboxChanges(since_state="2999", max_changes=47) 60 | ) == MailboxChangesResponse( 61 | account_id="u1138", 62 | old_state="2999", 63 | new_state="3000", 64 | has_more_changes=False, 65 | created=["MBX0001", "MBX0002"], 66 | updated=[], 67 | destroyed=["MBX0003"], 68 | ) 69 | 70 | 71 | def test_mailbox_get( 72 | client: Client, http_responses: responses.RequestsMock 73 | ) -> None: 74 | expected_request = { 75 | "methodCalls": [ 76 | [ 77 | "Mailbox/get", 78 | {"accountId": "u1138", "ids": ["MBX1", "MBX1000"]}, 79 | "single.Mailbox/get", 80 | ] 81 | ], 82 | "using": [ 83 | "urn:ietf:params:jmap:core", 84 | "urn:ietf:params:jmap:mail", 85 | ], 86 | } 87 | response = { 88 | "methodResponses": [ 89 | [ 90 | "Mailbox/get", 91 | { 92 | "accountId": "u1138", 93 | "list": [ 94 | { 95 | "id": "MBX1", 96 | "name": "First", 97 | "sortOrder": 1, 98 | "totalEmails": 100, 99 | "unreadEmails": 3, 100 | "totalThreads": 5, 101 | "unreadThreads": 1, 102 | "isSubscribed": True, 103 | }, 104 | { 105 | "id": "MBX1000", 106 | "name": "More Mailbox", 107 | "sortOrder": 42, 108 | "totalEmails": 10000, 109 | "unreadEmails": 99, 110 | "totalThreads": 5000, 111 | "unreadThreads": 90, 112 | "isSubscribed": False, 113 | }, 114 | ], 115 | "not_found": [], 116 | "state": "2187", 117 | }, 118 | "single.Mailbox/get", 119 | ] 120 | ] 121 | } 122 | expect_jmap_call(http_responses, expected_request, response) 123 | assert client.request( 124 | MailboxGet(ids=["MBX1", "MBX1000"]) 125 | ) == MailboxGetResponse( 126 | account_id="u1138", 127 | state="2187", 128 | not_found=[], 129 | data=[ 130 | Mailbox( 131 | id="MBX1", 132 | name="First", 133 | sort_order=1, 134 | total_emails=100, 135 | unread_emails=3, 136 | total_threads=5, 137 | unread_threads=1, 138 | is_subscribed=True, 139 | ), 140 | Mailbox( 141 | id="MBX1000", 142 | name="More Mailbox", 143 | sort_order=42, 144 | total_emails=10000, 145 | unread_emails=99, 146 | total_threads=5000, 147 | unread_threads=90, 148 | is_subscribed=False, 149 | ), 150 | ], 151 | ) 152 | 153 | 154 | def test_mailbox_query( 155 | client: Client, http_responses: responses.RequestsMock 156 | ) -> None: 157 | expected_request = { 158 | "methodCalls": [ 159 | [ 160 | "Mailbox/query", 161 | { 162 | "accountId": "u1138", 163 | "filter": { 164 | "name": "Inbox", 165 | }, 166 | "filterAsTree": False, 167 | "sortAsTree": False, 168 | }, 169 | "single.Mailbox/query", 170 | ] 171 | ], 172 | "using": [ 173 | "urn:ietf:params:jmap:core", 174 | "urn:ietf:params:jmap:mail", 175 | ], 176 | } 177 | response = { 178 | "methodResponses": [ 179 | [ 180 | "Mailbox/query", 181 | { 182 | "accountId": "u1138", 183 | "ids": ["MBX1", "MBX5"], 184 | "queryState": "4000", 185 | "canCalculateChanges": True, 186 | "position": 42, 187 | "total": 9001, 188 | "limit": 256, 189 | }, 190 | "single.Mailbox/query", 191 | ] 192 | ] 193 | } 194 | expect_jmap_call(http_responses, expected_request, response) 195 | assert client.request( 196 | MailboxQuery(filter=MailboxQueryFilterCondition(name="Inbox")) 197 | ) == MailboxQueryResponse( 198 | account_id="u1138", 199 | ids=["MBX1", "MBX5"], 200 | query_state="4000", 201 | can_calculate_changes=True, 202 | position=42, 203 | total=9001, 204 | limit=256, 205 | ) 206 | 207 | 208 | def test_mailbox_query_changes( 209 | client: Client, http_responses: responses.RequestsMock 210 | ) -> None: 211 | expected_request = { 212 | "methodCalls": [ 213 | [ 214 | "Mailbox/queryChanges", 215 | { 216 | "accountId": "u1138", 217 | "filter": { 218 | "name": "Inbox", 219 | }, 220 | "sinceQueryState": "1000", 221 | "calculateTotal": False, 222 | }, 223 | "single.Mailbox/queryChanges", 224 | ] 225 | ], 226 | "using": [ 227 | "urn:ietf:params:jmap:core", 228 | "urn:ietf:params:jmap:mail", 229 | ], 230 | } 231 | response = { 232 | "methodResponses": [ 233 | [ 234 | "Mailbox/queryChanges", 235 | { 236 | "accountId": "u1138", 237 | "oldQueryState": "1000", 238 | "newQueryState": "1003", 239 | "added": [ 240 | { 241 | "id": "MBX8002", 242 | "index": 3, 243 | }, 244 | { 245 | "id": "MBX8003", 246 | "index": 8, 247 | }, 248 | ], 249 | "removed": ["MBX8001"], 250 | "total": 42, 251 | }, 252 | "single.Mailbox/queryChanges", 253 | ] 254 | ] 255 | } 256 | expect_jmap_call(http_responses, expected_request, response) 257 | assert client.request( 258 | MailboxQueryChanges( 259 | filter=MailboxQueryFilterCondition(name="Inbox"), 260 | since_query_state="1000", 261 | ) 262 | ) == MailboxQueryChangesResponse( 263 | account_id="u1138", 264 | old_query_state="1000", 265 | new_query_state="1003", 266 | removed=["MBX8001"], 267 | added=[ 268 | AddedItem(id="MBX8002", index=3), 269 | AddedItem(id="MBX8003", index=8), 270 | ], 271 | total=42, 272 | ) 273 | 274 | 275 | def test_mailbox_set( 276 | client: Client, http_responses: responses.RequestsMock 277 | ) -> None: 278 | expected_request = { 279 | "methodCalls": [ 280 | [ 281 | "Mailbox/set", 282 | { 283 | "accountId": "u1138", 284 | "create": { 285 | "mailbox": { 286 | "name": "Saturn Valley Newsletter", 287 | "isSubscribed": False, 288 | "sortOrder": 0, 289 | } 290 | }, 291 | "onDestroyRemoveEmails": False, 292 | }, 293 | "single.Mailbox/set", 294 | ] 295 | ], 296 | "using": [ 297 | "urn:ietf:params:jmap:core", 298 | "urn:ietf:params:jmap:mail", 299 | ], 300 | } 301 | response = { 302 | "methodResponses": [ 303 | [ 304 | "Mailbox/set", 305 | { 306 | "accountId": "u1138", 307 | "oldState": "1", 308 | "newState": "2", 309 | "created": { 310 | "mailbox": { 311 | "showAsLabel": True, 312 | "totalThreads": 0, 313 | "unreadThreads": 0, 314 | "isSeenShared": False, 315 | "unreadEmails": 0, 316 | "myRights": { 317 | "maySubmit": True, 318 | "maySetKeywords": True, 319 | "mayAddItems": True, 320 | "mayAdmin": True, 321 | "mayRemoveItems": True, 322 | "mayDelete": True, 323 | "maySetSeen": True, 324 | "mayCreateChild": True, 325 | "mayRename": True, 326 | "mayReadItems": True, 327 | }, 328 | "totalEmails": 0, 329 | "id": "MBX9000", 330 | }, 331 | }, 332 | "updated": None, 333 | "destroyed": None, 334 | "notCreated": None, 335 | "notDestroyed": None, 336 | "notUpdated": None, 337 | }, 338 | "single.Mailbox/set", 339 | ] 340 | ] 341 | } 342 | expect_jmap_call(http_responses, expected_request, response) 343 | 344 | assert client.request( 345 | MailboxSet( 346 | create=dict(mailbox=Mailbox(name="Saturn Valley Newsletter")) 347 | ) 348 | ) == MailboxSetResponse( 349 | account_id="u1138", 350 | old_state="1", 351 | new_state="2", 352 | created=dict( 353 | mailbox=Mailbox( 354 | id="MBX9000", 355 | total_emails=0, 356 | unread_emails=0, 357 | total_threads=0, 358 | unread_threads=0, 359 | ) 360 | ), 361 | updated=None, 362 | destroyed=None, 363 | not_created=None, 364 | not_updated=None, 365 | not_destroyed=None, 366 | ) 367 | -------------------------------------------------------------------------------- /tests/methods/test_searchsnippet.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from jmapc import Client, EmailQueryFilterCondition, SearchSnippet 4 | from jmapc.methods import SearchSnippetGet, SearchSnippetGetResponse 5 | 6 | from ..utils import expect_jmap_call 7 | 8 | 9 | def test_search_snippet_get( 10 | client: Client, http_responses: responses.RequestsMock 11 | ) -> None: 12 | expected_request = { 13 | "methodCalls": [ 14 | [ 15 | "SearchSnippet/get", 16 | { 17 | "accountId": "u1138", 18 | "emailIds": ["M1234", "M1001"], 19 | "filter": {"text": "ness"}, 20 | }, 21 | "single.SearchSnippet/get", 22 | ] 23 | ], 24 | "using": [ 25 | "urn:ietf:params:jmap:core", 26 | "urn:ietf:params:jmap:mail", 27 | ], 28 | } 29 | response = { 30 | "methodResponses": [ 31 | [ 32 | "SearchSnippet/get", 33 | { 34 | "accountId": "u1138", 35 | "list": [ 36 | { 37 | "emailId": "M1234", 38 | "subject": "The year is 199x...", 39 | "preview": "Ness used PK Fire", 40 | }, 41 | { 42 | "emailId": "M1001", 43 | "subject": None, 44 | "preview": None, 45 | }, 46 | ], 47 | "not_found": [], 48 | }, 49 | "single.SearchSnippet/get", 50 | ] 51 | ] 52 | } 53 | expect_jmap_call(http_responses, expected_request, response) 54 | jmap_response = client.request( 55 | SearchSnippetGet( 56 | ids=["M1234", "M1001"], 57 | filter=EmailQueryFilterCondition(text="ness"), 58 | ) 59 | ) 60 | assert jmap_response == SearchSnippetGetResponse( 61 | account_id="u1138", 62 | not_found=[], 63 | data=[ 64 | SearchSnippet( 65 | email_id="M1234", 66 | subject="The year is 199x...", 67 | preview="Ness used PK Fire", 68 | ), 69 | SearchSnippet(email_id="M1001"), 70 | ], 71 | ) 72 | assert isinstance(jmap_response, SearchSnippetGetResponse) 73 | -------------------------------------------------------------------------------- /tests/methods/test_thread.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from jmapc import Client, Thread 4 | from jmapc.methods import ( 5 | ThreadChanges, 6 | ThreadChangesResponse, 7 | ThreadGet, 8 | ThreadGetResponse, 9 | ) 10 | 11 | from ..utils import expect_jmap_call 12 | 13 | 14 | def test_thread_changes( 15 | client: Client, http_responses: responses.RequestsMock 16 | ) -> None: 17 | expected_request = { 18 | "methodCalls": [ 19 | [ 20 | "Thread/changes", 21 | { 22 | "accountId": "u1138", 23 | "sinceState": "2999", 24 | "maxChanges": 47, 25 | }, 26 | "single.Thread/changes", 27 | ] 28 | ], 29 | "using": [ 30 | "urn:ietf:params:jmap:core", 31 | "urn:ietf:params:jmap:mail", 32 | ], 33 | } 34 | response = { 35 | "methodResponses": [ 36 | [ 37 | "Thread/changes", 38 | { 39 | "accountId": "u1138", 40 | "oldState": "2999", 41 | "newState": "3000", 42 | "hasMoreChanges": False, 43 | "created": ["T0001", "T0002"], 44 | "updated": [], 45 | "destroyed": ["T0003"], 46 | }, 47 | "single.Thread/changes", 48 | ] 49 | ] 50 | } 51 | expect_jmap_call(http_responses, expected_request, response) 52 | assert client.request( 53 | ThreadChanges(since_state="2999", max_changes=47) 54 | ) == ThreadChangesResponse( 55 | account_id="u1138", 56 | old_state="2999", 57 | new_state="3000", 58 | has_more_changes=False, 59 | created=["T0001", "T0002"], 60 | updated=[], 61 | destroyed=["T0003"], 62 | ) 63 | 64 | 65 | def test_thread_get( 66 | client: Client, http_responses: responses.RequestsMock 67 | ) -> None: 68 | expected_request = { 69 | "methodCalls": [ 70 | [ 71 | "Thread/get", 72 | {"accountId": "u1138", "ids": ["T1", "T1000"]}, 73 | "single.Thread/get", 74 | ] 75 | ], 76 | "using": [ 77 | "urn:ietf:params:jmap:core", 78 | "urn:ietf:params:jmap:mail", 79 | ], 80 | } 81 | response = { 82 | "methodResponses": [ 83 | [ 84 | "Thread/get", 85 | { 86 | "accountId": "u1138", 87 | "list": [ 88 | { 89 | "id": "T1", 90 | "emailIds": [ 91 | "M1234", 92 | "M2345", 93 | "M3456", 94 | ], 95 | }, 96 | { 97 | "id": "T1000", 98 | "emailIds": [ 99 | "M1001", 100 | ], 101 | }, 102 | ], 103 | "not_found": [], 104 | "state": "2187", 105 | }, 106 | "single.Thread/get", 107 | ] 108 | ] 109 | } 110 | expect_jmap_call(http_responses, expected_request, response) 111 | jmap_response = client.request(ThreadGet(ids=["T1", "T1000"])) 112 | assert jmap_response == ThreadGetResponse( 113 | account_id="u1138", 114 | state="2187", 115 | not_found=[], 116 | data=[ 117 | Thread( 118 | id="T1", 119 | email_ids=["M1234", "M2345", "M3456"], 120 | ), 121 | Thread( 122 | id="T1000", 123 | email_ids=["M1001"], 124 | ), 125 | ], 126 | ) 127 | assert isinstance(jmap_response, ThreadGetResponse) 128 | assert [len(thread) for thread in jmap_response.data] == [3, 1] 129 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | import requests 6 | import responses 7 | 8 | from jmapc import Blob, Client, ClientError, EmailBodyPart, constants 9 | from jmapc.auth import BearerAuth 10 | from jmapc.methods import ( 11 | CoreEcho, 12 | CoreEchoResponse, 13 | Invocation, 14 | InvocationResponseOrError, 15 | MailboxGet, 16 | MailboxGetResponse, 17 | Request, 18 | ) 19 | from jmapc.ref import Ref, ResultReference 20 | from jmapc.session import ( 21 | Session, 22 | SessionCapabilities, 23 | SessionCapabilitiesCore, 24 | SessionPrimaryAccount, 25 | ) 26 | 27 | from .data import make_session_response 28 | from .utils import expect_jmap_call 29 | 30 | echo_test_data = dict( 31 | who="Ness", goods=["Mr. Saturn coin", "Hall of Fame Bat"] 32 | ) 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "test_client", 37 | ( 38 | Client.create_with_api_token( 39 | "jmap-example.localhost", api_token="ness__pk_fire" 40 | ), 41 | Client.create_with_password( 42 | "jmap-example.localhost", user="ness", password="pk_fire" 43 | ), 44 | Client("jmap-example.localhost", auth=("ness", "pk_fire")), 45 | Client( 46 | "jmap-example.localhost", 47 | auth=requests.auth.HTTPBasicAuth( 48 | username="ness", password="pk_fire" 49 | ), 50 | ), 51 | Client("jmap-example.localhost", auth=BearerAuth("ness__pk_fire")), 52 | ), 53 | ) 54 | def test_jmap_session( 55 | test_client: Client, http_responses: responses.RequestsMock 56 | ) -> None: 57 | assert test_client.jmap_session == Session( 58 | username="ness@onett.example.net", 59 | api_url="https://jmap-api.localhost/api", 60 | download_url=( 61 | "https://jmap-api.localhost/jmap/download" 62 | "/{accountId}/{blobId}/{name}?type={type}" 63 | ), 64 | upload_url="https://jmap-api.localhost/jmap/upload/{accountId}/", 65 | event_source_url=( 66 | "https://jmap-api.localhost/events/{types}/{closeafter}/{ping}" 67 | ), 68 | capabilities=SessionCapabilities( 69 | core=SessionCapabilitiesCore( 70 | max_size_upload=50_000_000, 71 | max_concurrent_upload=4, 72 | max_size_request=10_000_000, 73 | max_concurrent_requests=4, 74 | max_calls_in_request=16, 75 | max_objects_in_get=500, 76 | max_objects_in_set=500, 77 | collation_algorithms={ 78 | "i;ascii-numeric", 79 | "i;ascii-casemap", 80 | "i;octet", 81 | }, 82 | ) 83 | ), 84 | primary_accounts=SessionPrimaryAccount( 85 | core="u1138", 86 | mail="u1138", 87 | submission="u1138", 88 | ), 89 | state="test;session;state", 90 | ) 91 | 92 | 93 | def test_jmap_session_no_account( 94 | http_responses_base: responses.RequestsMock, 95 | ) -> None: 96 | session_response = make_session_response() 97 | session_response["primaryAccounts"] = {} 98 | http_responses_base.add( 99 | method=responses.GET, 100 | url="https://jmap-example.localhost/.well-known/jmap", 101 | body=json.dumps(session_response), 102 | ) 103 | client = Client.create_with_api_token( 104 | "jmap-example.localhost", api_token="ness__pk_fire" 105 | ) 106 | with pytest.raises(Exception) as e: 107 | client.account_id 108 | assert str(e.value) == "No primary account ID found" 109 | 110 | 111 | @pytest.mark.parametrize( 112 | "urns", 113 | [ 114 | {constants.JMAP_URN_MAIL}, 115 | {constants.JMAP_URN_MAIL, constants.JMAP_URN_SUBMISSION}, 116 | { 117 | constants.JMAP_URN_MAIL, 118 | constants.JMAP_URN_SUBMISSION, 119 | "https://jmap.example.com/extra/capability", 120 | }, 121 | { 122 | "https://jmap.example.com/other/extra/capability", 123 | }, 124 | ], 125 | ) 126 | def test_jmap_session_capabilities_urns( 127 | client: Client, 128 | http_responses_base: responses.RequestsMock, 129 | urns: set[str], 130 | ) -> None: 131 | session_response = make_session_response() 132 | session_response["capabilities"].update({u: {} for u in urns}) 133 | http_responses_base.add( 134 | method=responses.GET, 135 | url="https://jmap-example.localhost/.well-known/jmap", 136 | body=json.dumps(session_response), 137 | ) 138 | assert client.jmap_session.capabilities.urns == ( 139 | {"urn:ietf:params:jmap:core"} | urns 140 | ) 141 | 142 | 143 | def test_client_request_updated_session( 144 | client: Client, http_responses: responses.RequestsMock 145 | ) -> None: 146 | new_session_response = make_session_response() 147 | new_session_response.update( 148 | { 149 | "state": "updated;state;value", 150 | "username": "paula@twoson.example.net", 151 | } 152 | ) 153 | http_responses.add( 154 | method=responses.GET, 155 | url="https://jmap-example.localhost/.well-known/jmap", 156 | body=json.dumps(new_session_response), 157 | ) 158 | method_params = CoreEcho(data=echo_test_data) 159 | expected_request = { 160 | "methodCalls": [ 161 | [ 162 | "Core/echo", 163 | echo_test_data, 164 | "single.Core/echo", 165 | ], 166 | ], 167 | "using": ["urn:ietf:params:jmap:core"], 168 | } 169 | response = { 170 | "methodResponses": [ 171 | [ 172 | "Core/echo", 173 | echo_test_data, 174 | "single.Core/echo", 175 | ], 176 | ], 177 | "sessionState": "updated;state;value", 178 | } 179 | expect_jmap_call(http_responses, expected_request, response) 180 | expected_response = CoreEchoResponse(data=echo_test_data) 181 | assert client.jmap_session.username == "ness@onett.example.net" 182 | assert client.request(method_params) == expected_response 183 | assert client.jmap_session.username == "paula@twoson.example.net" 184 | 185 | 186 | @pytest.mark.parametrize( 187 | "method_params", 188 | [ 189 | [CoreEcho(data=echo_test_data), MailboxGet(ids=Ref("/example"))], 190 | [ 191 | Invocation(method=CoreEcho(data=echo_test_data), id="0.Core/echo"), 192 | Invocation( 193 | method=MailboxGet(ids=Ref(path="/example")), id="1.Mailbox/get" 194 | ), 195 | ], 196 | [ 197 | Invocation(method=CoreEcho(data=echo_test_data), id="0.Core/echo"), 198 | MailboxGet(ids=Ref("/example", method="0.Core/echo")), 199 | ], 200 | [ 201 | CoreEcho(data=echo_test_data), 202 | MailboxGet( 203 | ids=ResultReference( 204 | path="/example", result_of="0.Core/echo", name="Core/echo" 205 | ) 206 | ), 207 | ], 208 | ], 209 | ids=[ 210 | "methods_only", 211 | "invocations_only", 212 | "method_and_invocation", 213 | "methods_with_manual_reference", 214 | ], 215 | ) 216 | def test_client_request( 217 | client: Client, 218 | http_responses: responses.RequestsMock, 219 | method_params: list[Request], 220 | ) -> None: 221 | expected_request = { 222 | "methodCalls": [ 223 | [ 224 | "Core/echo", 225 | echo_test_data, 226 | "0.Core/echo", 227 | ], 228 | [ 229 | "Mailbox/get", 230 | { 231 | "accountId": "u1138", 232 | "#ids": { 233 | "name": "Core/echo", 234 | "path": "/example", 235 | "resultOf": "0.Core/echo", 236 | }, 237 | }, 238 | "1.Mailbox/get", 239 | ], 240 | ], 241 | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 242 | } 243 | response = { 244 | "methodResponses": [ 245 | [ 246 | "Core/echo", 247 | echo_test_data, 248 | "0.Core/echo", 249 | ], 250 | [ 251 | "Mailbox/get", 252 | { 253 | "accountId": "u1138", 254 | "list": [], 255 | "not_found": [], 256 | "state": "1000", 257 | }, 258 | "1.Mailbox/get", 259 | ], 260 | ], 261 | } 262 | expect_jmap_call(http_responses, expected_request, response) 263 | assert client.request(method_params) == [ 264 | InvocationResponseOrError( 265 | response=CoreEchoResponse(data=echo_test_data), id="0.Core/echo" 266 | ), 267 | InvocationResponseOrError( 268 | response=MailboxGetResponse( 269 | account_id="u1138", 270 | not_found=[], 271 | data=[], 272 | state="1000", 273 | ), 274 | id="1.Mailbox/get", 275 | ), 276 | ] 277 | 278 | 279 | @pytest.mark.parametrize("raise_errors", [True, False]) 280 | def test_client_request_single( 281 | client: Client, http_responses: responses.RequestsMock, raise_errors: bool 282 | ) -> None: 283 | method_params = CoreEcho(data=echo_test_data) 284 | expected_request = { 285 | "methodCalls": [ 286 | [ 287 | "Core/echo", 288 | echo_test_data, 289 | "single.Core/echo", 290 | ], 291 | ], 292 | "using": ["urn:ietf:params:jmap:core"], 293 | } 294 | response = { 295 | "methodResponses": [ 296 | [ 297 | "Core/echo", 298 | echo_test_data, 299 | "single.Core/echo", 300 | ], 301 | ], 302 | } 303 | expect_jmap_call(http_responses, expected_request, response) 304 | expected_response = CoreEchoResponse(data=echo_test_data) 305 | if raise_errors: 306 | assert ( 307 | client.request(method_params, raise_errors=True) 308 | == expected_response 309 | ) 310 | else: 311 | assert ( 312 | client.request(method_params, raise_errors=False) 313 | == expected_response 314 | ) 315 | 316 | 317 | def test_client_request_single_with_multiple_responses( 318 | client: Client, 319 | http_responses: responses.RequestsMock, 320 | ) -> None: 321 | method_params = CoreEcho(data=echo_test_data) 322 | expected_request = { 323 | "methodCalls": [ 324 | [ 325 | "Core/echo", 326 | echo_test_data, 327 | "single.Core/echo", 328 | ], 329 | ], 330 | "using": ["urn:ietf:params:jmap:core"], 331 | } 332 | response = { 333 | "methodResponses": [ 334 | [ 335 | "Core/echo", 336 | echo_test_data, 337 | "single.Core/echo", 338 | ], 339 | [ 340 | "Core/echo", 341 | echo_test_data, 342 | "single.Core/echo", 343 | ], 344 | ], 345 | } 346 | expect_jmap_call(http_responses, expected_request, response) 347 | assert client.request(method_params) == [ 348 | CoreEchoResponse(data=echo_test_data), 349 | CoreEchoResponse(data=echo_test_data), 350 | ] 351 | 352 | 353 | def test_client_request_single_with_multiple_responses_error( 354 | client: Client, 355 | http_responses: responses.RequestsMock, 356 | ) -> None: 357 | method_params = CoreEcho(data=echo_test_data) 358 | expected_request = { 359 | "methodCalls": [ 360 | [ 361 | "Core/echo", 362 | echo_test_data, 363 | "single.Core/echo", 364 | ], 365 | ], 366 | "using": ["urn:ietf:params:jmap:core"], 367 | } 368 | response = { 369 | "methodResponses": [ 370 | [ 371 | "Core/echo", 372 | echo_test_data, 373 | "single.Core/echo", 374 | ], 375 | [ 376 | "Core/echo", 377 | echo_test_data, 378 | "single.Core/echo", 379 | ], 380 | ], 381 | } 382 | expect_jmap_call(http_responses, expected_request, response) 383 | with pytest.raises(ClientError) as e: 384 | client.request(method_params, single_response=True) 385 | assert ( 386 | str(e.value) 387 | == "2 method responses received for single method call Core/echo" 388 | ) 389 | assert e.value.result == 2 * [ 390 | InvocationResponseOrError( 391 | id="single.Core/echo", 392 | response=CoreEchoResponse(data=echo_test_data), 393 | ) 394 | ] 395 | 396 | 397 | def test_client_invalid_single_response_argument(client: Client) -> None: 398 | with pytest.raises(ValueError): 399 | client.request( 400 | [CoreEcho(data=echo_test_data), MailboxGet(ids=[])], 401 | single_response=True, 402 | ) # type: ignore 403 | 404 | 405 | def test_error_unauthorized( 406 | client: Client, http_responses: responses.RequestsMock 407 | ) -> None: 408 | http_responses.add( 409 | method=responses.POST, 410 | url="https://jmap-api.localhost/api", 411 | status=401, 412 | ) 413 | with pytest.raises(requests.exceptions.HTTPError) as e: 414 | client.request(CoreEcho(data=echo_test_data)) 415 | assert e.value.response.status_code == 401 416 | 417 | 418 | def test_upload_blob( 419 | client: Client, http_responses: responses.RequestsMock, tempdir: Path 420 | ) -> None: 421 | blob_content = "test upload blob content" 422 | source_file = tempdir / "upload.txt" 423 | source_file.write_text(blob_content) 424 | upload_response = { 425 | "accountId": "u1138", 426 | "blobId": "C2187", 427 | "type": "text/plain", 428 | "size": len(blob_content), 429 | } 430 | http_responses.add( 431 | method=responses.POST, 432 | url="https://jmap-api.localhost/jmap/upload/u1138/", 433 | body=json.dumps(upload_response), 434 | ) 435 | response = client.upload_blob(source_file) 436 | assert response == Blob( 437 | id="C2187", type="text/plain", size=len(blob_content) 438 | ) 439 | 440 | 441 | def test_download_attachment( 442 | client: Client, http_responses: responses.RequestsMock, tempdir: Path 443 | ) -> None: 444 | blob_content = "test download blob content" 445 | http_responses.add( 446 | method=responses.GET, 447 | url=( 448 | "https://jmap-api.localhost/jmap/download" 449 | "/u1138/C2187/download.txt?type=text/plain" 450 | ), 451 | body=blob_content, 452 | ) 453 | dest_file = tempdir / "download.txt" 454 | with pytest.raises(Exception) as e: 455 | client.download_attachment( 456 | EmailBodyPart( 457 | name="download.txt", blob_id="C2187", type="text/plain" 458 | ), 459 | "", 460 | ) 461 | assert str(e.value) == "Destination file name is required" 462 | assert not dest_file.exists() 463 | client.download_attachment( 464 | EmailBodyPart(name="download.txt", blob_id="C2187", type="text/plain"), 465 | dest_file, 466 | ) 467 | assert dest_file.read_text() == blob_content 468 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | from collections.abc import Iterable 4 | from unittest import mock 5 | 6 | import pytest 7 | import responses 8 | import sseclient 9 | 10 | from jmapc import Client, Event, EventSourceConfig, StateChange, TypeState 11 | 12 | from .data import make_session_response 13 | 14 | 15 | @pytest.fixture 16 | def mock_sseclient() -> Iterable[mock.MagicMock]: 17 | mock_client = mock.MagicMock( 18 | spec=sseclient.SSEClient, 19 | __iter__=lambda self: self, 20 | __next__=mock.MagicMock(side_effect=[None]), 21 | ) 22 | with mock.patch.object( 23 | sseclient, 24 | "SSEClient", 25 | mock_client, 26 | ): 27 | assert isinstance(sseclient.SSEClient, mock.MagicMock) 28 | sseclient.SSEClient.return_value.__iter__.side_effect = ( 29 | lambda: sseclient.SSEClient.return_value 30 | ) 31 | sseclient.SSEClient.return_value.__next__.side_effect = [] 32 | yield mock_client 33 | 34 | 35 | @pytest.mark.parametrize( 36 | ["event_source_url", "expected_call_url", "event_source_config"], 37 | [ 38 | ( 39 | "https://jmap-api.localhost/events/{types}/{closeafter}/{ping}", 40 | "https://jmap-api.localhost/events/*/no/0", 41 | None, 42 | ), 43 | ( 44 | "https://jmap-api.localhost/events/{types}/{closeafter}/{ping}", 45 | "https://jmap-api.localhost/events/Email,CalendarEvent/state/37", 46 | EventSourceConfig( 47 | types="Email,CalendarEvent", closeafter="state", ping=37 48 | ), 49 | ), 50 | ( 51 | "https://jmap-api.localhost/events/{ping}", 52 | "https://jmap-api.localhost/events/0", 53 | None, 54 | ), 55 | ( 56 | "https://jmap-api.localhost/events/{ping}", 57 | "https://jmap-api.localhost/events/299", 58 | EventSourceConfig(ping=299), 59 | ), 60 | ( 61 | "https://jmap-api.localhost/events/", 62 | "https://jmap-api.localhost/events/", 63 | None, 64 | ), 65 | ], 66 | ) 67 | def test_event_source_url( 68 | mock_sseclient: mock.MagicMock, 69 | event_source_url: str, 70 | expected_call_url: str, 71 | event_source_config: Optional[EventSourceConfig], 72 | ) -> None: 73 | client = Client( 74 | host="jmap-example.localhost", 75 | auth=("ness", "pk_fire"), 76 | event_source_config=event_source_config, 77 | ) 78 | with responses.RequestsMock() as resp_mock: 79 | session_response = make_session_response() 80 | session_response["eventSourceUrl"] = event_source_url 81 | resp_mock.add( 82 | method=responses.GET, 83 | url="https://jmap-example.localhost/.well-known/jmap", 84 | body=json.dumps(session_response), 85 | ) 86 | with pytest.raises(StopIteration): 87 | next(client.events) 88 | mock_sseclient.assert_called_once_with( 89 | expected_call_url, 90 | auth=("ness", "pk_fire"), 91 | last_id=None, 92 | ) 93 | 94 | 95 | def test_event_source( 96 | client: Client, 97 | mock_sseclient: mock.MagicMock, 98 | http_responses: responses.RequestsMock, 99 | ) -> None: 100 | mock_events = [ 101 | sseclient.Event( 102 | id="8001", 103 | event="state", 104 | data=json.dumps({"changed": {"u1138": {"Email": "1001"}}}), 105 | ), 106 | sseclient.Event( 107 | id="8001.5", 108 | event="ping", 109 | data="ignore-me", 110 | ), 111 | sseclient.Event( 112 | id="8002", 113 | event="state", 114 | data=json.dumps( 115 | { 116 | "changed": { 117 | "u1138": { 118 | "CalendarEvent": "1011", 119 | "Email": "1001", 120 | "EmailDelivery": "1003", 121 | "Mailbox": "1021", 122 | "Thread": "1020", 123 | } 124 | } 125 | } 126 | ), 127 | ), 128 | sseclient.Event( 129 | id="", 130 | event="ping", 131 | data="also-ignore-me", 132 | ), 133 | sseclient.Event( 134 | event="state", 135 | data=json.dumps( 136 | {"changed": {"u1138": {"Email": "2000", "Mailbox": "2222"}}} 137 | ), 138 | ), 139 | ] 140 | expected_events = [ 141 | Event( 142 | id="8001", 143 | data=StateChange(changed={"u1138": TypeState(email="1001")}), 144 | ), 145 | Event( 146 | id="8002", 147 | data=StateChange( 148 | changed={ 149 | "u1138": TypeState( 150 | calendar_event="1011", 151 | email="1001", 152 | email_delivery="1003", 153 | mailbox="1021", 154 | thread="1020", 155 | ) 156 | } 157 | ), 158 | ), 159 | Event( 160 | id=None, 161 | data=StateChange( 162 | changed={ 163 | "u1138": TypeState( 164 | email="2000", 165 | mailbox="2222", 166 | ) 167 | } 168 | ), 169 | ), 170 | ] 171 | assert isinstance(sseclient.SSEClient, mock.MagicMock) 172 | sseclient.SSEClient.return_value.__next__.side_effect = mock_events 173 | for expected_event in expected_events: 174 | event = next(client.events) 175 | assert event == expected_event 176 | with pytest.raises(StopIteration): 177 | next(client.events) 178 | mock_sseclient.assert_called_once_with( 179 | "https://jmap-api.localhost/events/*/no/0", 180 | auth=("ness", "pk_fire"), 181 | last_id=None, 182 | ) 183 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | def test_import() -> None: 2 | import jmapc 3 | 4 | assert jmapc.Client # type: ignore[truthy-function] 5 | assert jmapc.methods 6 | assert jmapc.models 7 | assert jmapc.errors 8 | -------------------------------------------------------------------------------- /tests/test_ref.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jmapc import Ref, ResultReference 4 | from jmapc.methods import Invocation, MailboxGet, MailboxQuery 5 | 6 | 7 | def test_ref_with_no_method_calls() -> None: 8 | method = MailboxGet( 9 | ids=Ref("/ids"), 10 | ) 11 | with pytest.raises(ValueError): 12 | method.to_dict() 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "invalid_ref", 17 | [ 18 | Ref("/ids", method="1.does_not_exist"), 19 | Ref("/ids", method=-6), 20 | ], 21 | ) 22 | def test_ref_with_no_method_match(invalid_ref: Ref) -> None: 23 | method = MailboxGet( 24 | ids=invalid_ref, 25 | ) 26 | with pytest.raises(IndexError): 27 | method.to_dict( 28 | method_calls_slice=[ 29 | Invocation(id="0.example", method=MailboxGet(ids=[])) 30 | ] 31 | ) 32 | 33 | 34 | def test_invalid_ref_object() -> None: 35 | bad_ref = Ref("/ids") 36 | bad_ref._ref_sentinel = "invalid_value" 37 | method = MailboxGet(ids=bad_ref) 38 | with pytest.raises(ValueError): 39 | method.to_dict() 40 | 41 | 42 | def test_method_with_result_reference() -> None: 43 | method = MailboxGet( 44 | ids=ResultReference( 45 | name=MailboxQuery.get_method_name(), 46 | path="/ids", 47 | result_of="0", 48 | ), 49 | ) 50 | assert method.to_dict() == { 51 | "#ids": {"name": "Mailbox/query", "path": "/ids", "resultOf": "0"} 52 | } 53 | -------------------------------------------------------------------------------- /tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime, timezone 3 | from typing import Any, Optional 4 | 5 | import pytest 6 | from dataclasses_json import config 7 | 8 | from jmapc import EmailHeader, ResultReference 9 | from jmapc.models import ListOrRef 10 | from jmapc.serializer import Model, datetime_decode, datetime_encode 11 | 12 | 13 | def test_camel_case() -> None: 14 | @dataclass 15 | class TestModel(Model): 16 | camel_case_key: str 17 | 18 | d = TestModel(camel_case_key="fourside") 19 | to_dict = d.to_dict() 20 | assert to_dict == dict(camelCaseKey="fourside") 21 | from_dict = TestModel.from_dict(to_dict) 22 | assert from_dict == d 23 | 24 | 25 | def test_serialize_result_reference() -> None: 26 | @dataclass 27 | class TestModel(Model): 28 | ids: ListOrRef[str] 29 | 30 | d = TestModel( 31 | ids=ResultReference( 32 | name="Some/method", 33 | path="/ids", 34 | result_of="method0", 35 | ), 36 | ) 37 | to_dict = d.to_dict() 38 | assert to_dict == { 39 | "#ids": {"name": "Some/method", "path": "/ids", "resultOf": "method0"} 40 | } 41 | 42 | 43 | def test_serialize_header() -> None: 44 | @dataclass 45 | class TestModel(Model): 46 | headers: list[EmailHeader] 47 | 48 | d = TestModel( 49 | headers=[ 50 | EmailHeader(name="name", value="value"), 51 | ], 52 | ) 53 | to_dict = d.to_dict() 54 | assert to_dict == { 55 | "header:name": "value", 56 | } 57 | 58 | 59 | def test_serialize_header_2() -> None: 60 | @dataclass 61 | class TestModel(Model): 62 | headers: list[EmailHeader] 63 | 64 | d = TestModel( 65 | headers=[ 66 | EmailHeader(name="name", value="value"), 67 | ], 68 | ) 69 | to_dict = d.to_dict() 70 | assert to_dict == { 71 | "header:name": "value", 72 | } 73 | 74 | 75 | def test_serialize_add_account_id() -> None: 76 | @dataclass 77 | class TestModel(Model): 78 | account_id: Optional[str] = field(init=False) 79 | data: str 80 | 81 | d = TestModel( 82 | data="is beautiful", 83 | ) 84 | to_dict = d.to_dict(account_id="u1138") 85 | assert to_dict == dict(accountId="u1138", data="is beautiful") 86 | 87 | 88 | @pytest.mark.parametrize( 89 | ["dt", "expected_dict"], 90 | [ 91 | ( 92 | datetime(2022, 2, 26, 12, 31, 45, tzinfo=timezone.utc), 93 | dict(timestamp="2022-02-26T12:31:45Z"), 94 | ), 95 | (None, dict()), 96 | ], 97 | ) 98 | def test_serialize_datetime( 99 | dt: datetime, expected_dict: dict[str, Any] 100 | ) -> None: 101 | @dataclass 102 | class TestModel(Model): 103 | timestamp: Optional[datetime] = field( 104 | default=None, 105 | metadata=config(encoder=datetime_encode, decoder=datetime_decode), 106 | ) 107 | 108 | d = TestModel(timestamp=dt) 109 | to_dict = d.to_dict() 110 | assert to_dict == expected_dict 111 | from_dict = TestModel.from_dict(to_dict) 112 | assert from_dict == d 113 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | from typing import Any, Callable 4 | 5 | import requests 6 | import responses 7 | 8 | 9 | def assert_request_return_response( 10 | expected_request: dict[str, Any], 11 | response: dict[str, Any], 12 | ) -> Callable[[requests.PreparedRequest], tuple[int, dict[str, str], str]]: 13 | def _response_callback( 14 | expected_request: dict[str, Any], 15 | response: dict[str, Any], 16 | request: requests.PreparedRequest, 17 | ) -> tuple[int, dict[str, str], str]: 18 | assert request.headers["Content-Type"] == "application/json" 19 | assert json.loads(request.body or "{}") == expected_request 20 | return (200, dict(), json.dumps(response)) 21 | 22 | return functools.partial(_response_callback, expected_request, response) 23 | 24 | 25 | def expect_jmap_call( 26 | http_responses: responses.RequestsMock, 27 | expected_request: dict[str, Any], 28 | response: dict[str, Any], 29 | ) -> None: 30 | response.setdefault("sessionState", "test;session;state") 31 | http_responses.add_callback( 32 | method=responses.POST, 33 | url="https://jmap-api.localhost/api", 34 | callback=assert_request_return_response(expected_request, response), 35 | ) 36 | -------------------------------------------------------------------------------- /types/sseclient.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | class SSEClient: 4 | def __init__( 5 | self, 6 | url: str, 7 | last_id: Optional[str] = None, 8 | retry: int = 3000, 9 | session: Optional[Any] = None, 10 | chunk_size: int = 1024, 11 | **kwargs: Any, 12 | ): 13 | pass 14 | 15 | def __iter__(self) -> SSEClient: 16 | pass 17 | 18 | def __next__(self) -> "Event": 19 | pass 20 | 21 | class Event: 22 | def __init__( 23 | self, 24 | data: str = "", 25 | event: str = "message", 26 | id: Optional[str] = None, 27 | retry: Optional[str] = None, 28 | ): 29 | self.data: str 30 | self.event: str 31 | self.id: str 32 | self.retry: str 33 | --------------------------------------------------------------------------------