From 10e797a1aab0749765760d69ff1baca1a65f26db Mon Sep 17 00:00:00 2001 From: Afonso Coutinho Date: Wed, 3 Jun 2026 05:44:18 +0100 Subject: [PATCH] Normalize scheduled email offsets before storage Normalize scheduled email send_at values with timezone offsets or Z suffixes to naive UTC before storing, matching the poller's lexicographic comparison format and preventing early/late sends. --- routes/email_routes.py | 9 ++ ...est_schedule_email_offset_normalization.py | 100 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/test_schedule_email_offset_normalization.py diff --git a/routes/email_routes.py b/routes/email_routes.py index 35d5f6b..129be0a 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -1979,6 +1979,15 @@ def setup_email_routes(): # minute doesn't trip the past-time guard. if parsed_at < now_utc: return {"success": False, "error": "send_at must be in the future"} + # Normalize to naive UTC before storing: the poller selects due + # rows with a lexicographic string compare against a naive + # datetime.utcnow().isoformat(), so storing the raw client string + # makes "+02:00" schedules fire hours late, negative offsets fire + # hours early, and a "Z" suffix compares after the fractional + # seconds of the poller timestamp. + if parsed_at.tzinfo: + parsed_at = parsed_at.astimezone(_tz.utc).replace(tzinfo=None) + send_at = parsed_at.isoformat() sid = _uuid.uuid4().hex[:16] conn = sqlite3.connect(SCHEDULED_DB) diff --git a/tests/test_schedule_email_offset_normalization.py b/tests/test_schedule_email_offset_normalization.py new file mode 100644 index 0000000..96a9b61 --- /dev/null +++ b/tests/test_schedule_email_offset_normalization.py @@ -0,0 +1,100 @@ +"""Scheduled emails with a TZ offset or Z suffix must fire on time. + +POST /api/email/schedule validated send_at by parsing it (handling Z and +offsets) but stored the RAW client string. The poller selects due rows +with a lexicographic string compare against a naive UTC isoformat, so a +"17:01:00+02:00" schedule (15:01 UTC) did not fire until 17:01 UTC (~2h +late) and a "13:00:00-05:00" schedule (18:00 UTC) fired at 13:00 UTC (5h +early). +""" + +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest + + +def _route_endpoint(router, path: str, method: str): + method = method.upper() + for route in router.routes: + if route.path == path and method in getattr(route, "methods", set()): + return route.endpoint + raise AssertionError(f"route not found: {method} {path}") + + +@pytest.fixture +def schedule(tmp_path, monkeypatch): + import routes.email_helpers as email_helpers + import routes.email_routes as email_routes + + db_path = tmp_path / "scheduled_emails.db" + monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path) + monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path) + email_helpers._init_scheduled_db() + router = email_routes.setup_email_routes() + endpoint = _route_endpoint(router, "/api/email/schedule", "POST") + + def _stored(sid): + row = sqlite3.connect(db_path).execute( + "SELECT send_at FROM scheduled_emails WHERE id = ?", (sid,) + ).fetchone() + return row[0] + + return endpoint, _stored + + +@pytest.mark.asyncio +async def test_positive_offset_stored_as_naive_utc(schedule): + endpoint, stored = schedule + local = datetime.now(timezone(timedelta(hours=2))) + timedelta(hours=1) + res = await endpoint( + {"to": "a@example.com", "body": "b", "send_at": local.isoformat()}, + owner="alice", + ) + assert res["success"] is True + expected = local.astimezone(timezone.utc).replace(tzinfo=None).isoformat() + value = stored(res["id"]) + assert value == expected + # the poller's lexicographic dueness check now flips at the right time + utc_due = local.astimezone(timezone.utc).replace(tzinfo=None) + assert value <= (utc_due + timedelta(minutes=1)).isoformat() + assert not value <= (utc_due - timedelta(minutes=1)).isoformat() + + +@pytest.mark.asyncio +async def test_negative_offset_does_not_fire_early(schedule): + endpoint, stored = schedule + local = datetime.now(timezone(timedelta(hours=-5))) + timedelta(hours=3) + res = await endpoint( + {"to": "a@example.com", "body": "b", "send_at": local.isoformat()}, + owner="alice", + ) + assert res["success"] is True + value = stored(res["id"]) + # on the old code the raw "-05:00" string compared as 3h+(-5h offset) + # in the past and fired on the next poller tick + assert not value <= datetime.utcnow().isoformat() + + +@pytest.mark.asyncio +async def test_z_suffix_stored_without_suffix(schedule): + endpoint, stored = schedule + utc = datetime.now(timezone.utc) + timedelta(hours=1) + send_at = utc.replace(tzinfo=None).isoformat() + "Z" + res = await endpoint( + {"to": "a@example.com", "body": "b", "send_at": send_at}, + owner="alice", + ) + assert res["success"] is True + assert stored(res["id"]) == utc.replace(tzinfo=None).isoformat() + + +@pytest.mark.asyncio +async def test_naive_utc_send_at_unchanged(schedule): + endpoint, stored = schedule + naive = (datetime.utcnow() + timedelta(days=1)).isoformat() + res = await endpoint( + {"to": "a@example.com", "body": "b", "send_at": naive}, owner="alice" + ) + assert res["success"] is True + assert stored(res["id"]) == naive