├── .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]
4 | [][pypi]
5 | [][gh-actions]
6 | [][codecov]
7 | [][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 |
--------------------------------------------------------------------------------