Skip to content

Commit 4da88eb

Browse files
feat(jmap): add @type Event, Stalwart Docker test server, session URL port rewriting
1 parent 7841870 commit 4da88eb

10 files changed

Lines changed: 297 additions & 16 deletions

File tree

caldav/jmap/async_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ async def delete_event(self, event_id: str) -> None:
396396
async def _get_object_by_uid(
397397
self, uid: str, calendar_id: str | None = None, parent: JMAPCalendar | None = None
398398
) -> JMAPCalendarObject:
399+
# RFC 8984 FilterCondition has no uid field; UID matching is done client-side.
399400
for obj in await self._search(calendar_id=calendar_id, parent=parent):
400401
if obj.data.get("uid") == uid:
401402
return obj

caldav/jmap/convert/ical_to_jscal.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict:
332332
duration = "P0D"
333333

334334
jscal: dict = {
335+
"@type": "Event",
335336
"uid": uid,
336337
"title": title,
337338
"start": start,

caldav/jmap/session.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from __future__ import annotations
99

1010
from dataclasses import dataclass, field
11-
from urllib.parse import urljoin
11+
from urllib.parse import urljoin, urlparse, urlunparse
1212

1313
try:
1414
import niquests as requests
@@ -57,6 +57,15 @@ def _parse_session_data(url: str, data: dict) -> Session:
5757
# return a relative path. Resolve it against the session endpoint URL.
5858
api_url = urljoin(url, api_url)
5959

60+
# Some servers (e.g. Stalwart behind a port-remapping proxy) advertise an
61+
# api_url whose host matches ours but whose port reflects the internal
62+
# listener rather than the port we actually connected through. Rewrite to
63+
# match the session endpoint's authority so subsequent calls succeed.
64+
session_parsed = urlparse(url)
65+
api_parsed = urlparse(api_url)
66+
if api_parsed.hostname == session_parsed.hostname and api_parsed.port != session_parsed.port:
67+
api_url = urlunparse(api_parsed._replace(netloc=session_parsed.netloc))
68+
6069
state = data.get("state", "")
6170
server_capabilities = data.get("capabilities", {})
6271
accounts = data.get("accounts", {})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
version: '3.8'
2+
3+
services:
4+
stalwart:
5+
image: stalwartlabs/stalwart:latest
6+
container_name: stalwart-test
7+
hostname: localhost
8+
ports:
9+
- "8806:8080" # HTTP (JMAP + admin UI)
10+
# Note: Stalwart advertises its internal port (8080) in JMAP session URLs.
11+
# The client must rewrite api_url to use the host-side port (8806) when
12+
# the session URL host matches the configured server hostname.
13+
tmpfs:
14+
- /opt/stalwart:size=200m
15+
healthcheck:
16+
test: ["CMD", "curl", "-sf", "http://localhost:8080/"]
17+
interval: 10s
18+
timeout: 5s
19+
retries: 10
20+
start_period: 30s
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/bin/bash
2+
# Setup script for Stalwart JMAP test server.
3+
# Waits for the container to be ready, extracts the auto-generated admin
4+
# password, creates a test user via the Management REST API, and verifies
5+
# the JMAP endpoint is reachable.
6+
7+
set -e
8+
9+
BASE_URL="http://localhost:8806"
10+
11+
echo "Waiting for Stalwart HTTP..."
12+
for i in $(seq 1 30); do
13+
if curl -sf "${BASE_URL}/" >/dev/null 2>&1; then
14+
echo "HTTP ready."
15+
break
16+
fi
17+
[ "$i" -eq 30 ] && { echo "ERROR: Stalwart did not start in time"; exit 1; }
18+
sleep 2
19+
done
20+
21+
# Stalwart prints the auto-generated admin password on first run.
22+
# Example log line: "Your administrator account is 'admin' with password 'XYZ'."
23+
ADMIN_PASS=$(docker logs stalwart-test 2>&1 | grep -o "password '[^']*'" | head -1 | sed "s/password '//;s/'//")
24+
if [ -z "$ADMIN_PASS" ]; then
25+
echo "ERROR: Could not extract admin password from container logs"
26+
echo "Container logs:"
27+
docker logs stalwart-test 2>&1 | tail -20
28+
exit 1
29+
fi
30+
echo "Admin password extracted."
31+
32+
# Create the test domain (required before creating users with that domain).
33+
HTTP_STATUS=$(curl -s -o /tmp/stalwart_domain.json -w "%{http_code}" \
34+
-u "admin:${ADMIN_PASS}" \
35+
-X POST "${BASE_URL}/api/principal" \
36+
-H "Content-Type: application/json" \
37+
-d '{"type":"domain","name":"localhost"}')
38+
39+
if [ "$HTTP_STATUS" != "200" ]; then
40+
echo "ERROR: Failed to create domain (HTTP ${HTTP_STATUS})"
41+
cat /tmp/stalwart_domain.json 2>/dev/null || true
42+
exit 1
43+
fi
44+
echo "Domain 'localhost' created."
45+
46+
# Create test user via Stalwart Management REST API (POST /api/principal).
47+
# The "user" role grants standard JMAP access permissions.
48+
HTTP_STATUS=$(curl -s -o /tmp/stalwart_create.json -w "%{http_code}" \
49+
-u "admin:${ADMIN_PASS}" \
50+
-X POST "${BASE_URL}/api/principal" \
51+
-H "Content-Type: application/json" \
52+
-d '{"type":"individual","name":"user1","secrets":["x"],"emails":["user1@localhost"],"roles":["user"]}')
53+
54+
if [ "$HTTP_STATUS" != "200" ]; then
55+
echo "ERROR: Failed to create test user (HTTP ${HTTP_STATUS})"
56+
cat /tmp/stalwart_create.json 2>/dev/null || true
57+
exit 1
58+
fi
59+
echo "Test user created: user1 / x"
60+
61+
# Verify JMAP endpoint is reachable with test credentials.
62+
echo "Verifying JMAP endpoint..."
63+
for i in $(seq 1 15); do
64+
if curl -sf -u "user1:x" "${BASE_URL}/.well-known/jmap" >/dev/null 2>&1; then
65+
echo "JMAP ready."
66+
break
67+
fi
68+
[ "$i" -eq 15 ] && { echo "ERROR: JMAP endpoint not reachable after user creation"; exit 1; }
69+
sleep 2
70+
done
71+
72+
echo ""
73+
echo "Stalwart setup complete."
74+
echo " JMAP URL: ${BASE_URL}/.well-known/jmap"
75+
echo " Username: user1"
76+
echo " Password: x"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/bash
2+
# Quick start script for Stalwart JMAP test server.
3+
#
4+
# Usage: ./start.sh
5+
6+
set -e
7+
8+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9+
cd "$SCRIPT_DIR"
10+
11+
echo "Cleaning up previous Stalwart instance..."
12+
docker-compose down 2>/dev/null || true
13+
14+
echo "Starting Stalwart Mail Server..."
15+
docker-compose up -d
16+
17+
echo ""
18+
echo "Waiting for Stalwart to initialize..."
19+
./setup_stalwart.sh
20+
21+
echo ""
22+
echo "To stop Stalwart: ./stop.sh"
23+
echo "To view logs: docker-compose logs -f stalwart"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/bin/bash
2+
# Stop script for Stalwart JMAP test server.
3+
4+
set -e
5+
6+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7+
cd "$SCRIPT_DIR"
8+
9+
echo "Stopping Stalwart and removing volumes..."
10+
docker-compose down -v
11+
12+
echo "Stalwart stopped."

tests/test_jmap_integration.py

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
"""
2-
Integration tests for the caldav.jmap package against a live Cyrus IMAP server.
3-
4-
These tests require the Cyrus Docker container to be running:
2+
Integration tests for the caldav.jmap package against live JMAP servers.
53
4+
Cyrus (port 8802):
65
docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d
76
8-
If the server is not reachable on port 8802 the entire module is skipped
9-
automatically — no failure, no noise.
7+
Stalwart (port 8806):
8+
docker-compose -f tests/docker-test-servers/stalwart/docker-compose.yml up -d
9+
./tests/docker-test-servers/stalwart/setup_stalwart.sh
1010
11-
Cyrus JMAP endpoint: http://localhost:8802/.well-known/jmap
12-
Test credentials: user1 / x
11+
Each server's test classes are skipped automatically when that server is not
12+
reachable — no failure, no noise.
1313
"""
1414

15+
import socket
1516
import uuid
1617
from datetime import datetime, timedelta, timezone
1718

@@ -31,23 +32,32 @@
3132

3233
CYRUS_HOST = "localhost"
3334
CYRUS_PORT = 8802
34-
JMAP_URL = f"http://{CYRUS_HOST}:{CYRUS_PORT}/.well-known/jmap"
35+
CYRUS_JMAP_URL = f"http://{CYRUS_HOST}:{CYRUS_PORT}/.well-known/jmap"
3536
CYRUS_USERNAME = "user1"
3637
CYRUS_PASSWORD = "x"
3738

39+
STALWART_HOST = "localhost"
40+
STALWART_PORT = 8806
41+
STALWART_JMAP_URL = f"http://{STALWART_HOST}:{STALWART_PORT}/.well-known/jmap"
42+
STALWART_USERNAME = "user1"
43+
STALWART_PASSWORD = "x"
3844

39-
def _cyrus_reachable() -> bool:
40-
import socket
4145

46+
def _reachable(host: str, port: int) -> bool:
4247
try:
43-
with socket.create_connection((CYRUS_HOST, CYRUS_PORT), timeout=2):
48+
with socket.create_connection((host, port), timeout=2):
4449
return True
4550
except OSError:
4651
return False
4752

4853

54+
_cyrus_up = _reachable(CYRUS_HOST, CYRUS_PORT)
55+
_stalwart_up = _reachable(STALWART_HOST, STALWART_PORT)
56+
57+
# Backward-compatible alias used by the module-level pytestmark below.
58+
# The mark only gates tests that don't carry their own skipif marker.
4959
pytestmark = pytest.mark.skipif(
50-
not _cyrus_reachable(),
60+
not _cyrus_up,
5161
reason=f"Cyrus Docker not reachable on {CYRUS_HOST}:{CYRUS_PORT} — "
5262
"start it with: docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d",
5363
)
@@ -74,12 +84,12 @@ def _minimal_ical(title: str = "Test Event", start: datetime | None = None) -> s
7484

7585
@pytest.fixture(scope="module")
7686
def client():
77-
return JMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
87+
return JMAPClient(url=CYRUS_JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
7888

7989

8090
@pytest.fixture(scope="module")
8191
def session():
82-
return fetch_session(JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD))
92+
return fetch_session(CYRUS_JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD))
8393

8494

8595
@pytest.fixture(scope="module")
@@ -101,7 +111,7 @@ def created_event_id(client, calendar_id):
101111

102112
@pytest_asyncio.fixture
103113
async def async_client():
104-
return AsyncJMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
114+
return AsyncJMAPClient(url=CYRUS_JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
105115

106116

107117
@pytest_asyncio.fixture
@@ -123,6 +133,37 @@ async def async_created_event_id(async_client, async_calendar_id):
123133
pass
124134

125135

136+
_stalwart_skip = pytest.mark.skipif(
137+
not _stalwart_up,
138+
reason=f"Stalwart Docker not reachable on {STALWART_HOST}:{STALWART_PORT} — "
139+
"start it with: cd tests/docker-test-servers/stalwart && ./start.sh",
140+
)
141+
142+
143+
@pytest.fixture(scope="module")
144+
def stalwart_client():
145+
return JMAPClient(url=STALWART_JMAP_URL, username=STALWART_USERNAME, password=STALWART_PASSWORD)
146+
147+
148+
@pytest.fixture(scope="module")
149+
def stalwart_calendar_id(stalwart_client):
150+
calendars = stalwart_client.get_calendars()
151+
assert calendars, "Stalwart did not return any calendars for user1"
152+
return calendars[0].id
153+
154+
155+
@pytest.fixture
156+
def stalwart_event_id(stalwart_client, stalwart_calendar_id):
157+
event_id = stalwart_client.create_event(
158+
stalwart_calendar_id, _minimal_ical("Stalwart Test Event")
159+
)
160+
yield event_id
161+
try:
162+
stalwart_client.delete_event(event_id)
163+
except Exception:
164+
pass
165+
166+
126167
class TestJMAPSessionIntegration:
127168
def test_session_fetch_returns_api_url(self, session):
128169
assert session.api_url
@@ -256,3 +297,60 @@ async def test_ical_roundtrip(self, async_client, async_calendar_id):
256297
assert "20260715" in fetched
257298
finally:
258299
await async_client.delete_event(event_id)
300+
301+
302+
@_stalwart_skip
303+
class TestStalwartJMAPCalendarListIntegration:
304+
def test_list_calendars_returns_list(self, stalwart_client):
305+
calendars = stalwart_client.get_calendars()
306+
assert isinstance(calendars, list)
307+
308+
def test_calendars_have_id_and_name(self, stalwart_client):
309+
calendars = stalwart_client.get_calendars()
310+
assert len(calendars) >= 1, "Expected at least one calendar on Stalwart for user1"
311+
for cal in calendars:
312+
assert cal.id, f"Calendar missing id: {cal}"
313+
assert cal.name, f"Calendar has empty name: {cal}"
314+
315+
316+
@_stalwart_skip
317+
class TestStalwartJMAPEventIntegration:
318+
def test_event_create_get(self, stalwart_client, stalwart_event_id):
319+
obj = stalwart_client.get_event(stalwart_event_id)
320+
ical = jscal_to_ical(obj.get_data())
321+
assert "BEGIN:VCALENDAR" in ical
322+
assert "Stalwart Test Event" in ical
323+
324+
def test_event_update(self, stalwart_client, stalwart_event_id):
325+
stalwart_client.update_event(stalwart_event_id, _minimal_ical("Stalwart Updated Title"))
326+
obj = stalwart_client.get_event(stalwart_event_id)
327+
assert "Stalwart Updated Title" in jscal_to_ical(obj.get_data())
328+
329+
def test_event_delete(self, stalwart_client, stalwart_calendar_id):
330+
event_id = stalwart_client.create_event(
331+
stalwart_calendar_id, _minimal_ical("Stalwart To Be Deleted")
332+
)
333+
stalwart_client.delete_event(event_id)
334+
with pytest.raises(JMAPMethodError):
335+
stalwart_client.get_event(event_id)
336+
337+
def test_event_query_time_range(self, stalwart_client, stalwart_event_id):
338+
# Stalwart does not support the inCalendars filter; query without calendar_id.
339+
results = stalwart_client.search_events(
340+
start="2026-06-01T00:00:00",
341+
end="2026-06-02T00:00:00",
342+
)
343+
assert len(results) >= 1
344+
assert any("Stalwart Test Event" in jscal_to_ical(r.get_data()) for r in results)
345+
346+
def test_ical_roundtrip(self, stalwart_client, stalwart_calendar_id):
347+
start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc)
348+
event_id = stalwart_client.create_event(
349+
stalwart_calendar_id, _minimal_ical("Stalwart Roundtrip Event", start=start)
350+
)
351+
try:
352+
fetched = jscal_to_ical(stalwart_client.get_event(event_id).get_data())
353+
assert "Stalwart Roundtrip Event" in fetched
354+
assert "20260715" in fetched
355+
finally:
356+
stalwart_client.delete_event(event_id)

tests/test_jmap_unit.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,7 @@ class TestIcalToJscal:
984984
def test_minimal_event(self):
985985
ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Test Event\r\n")
986986
result = ical_to_jscal(ical)
987+
assert result["@type"] == "Event"
987988
assert result["uid"] == "test-uid@example.com"
988989
assert result["title"] == "Test Event"
989990
assert result["start"] == "2024-06-15T10:00:00"

0 commit comments

Comments
 (0)