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.
This commit is contained in:
@@ -1979,6 +1979,15 @@ def setup_email_routes():
|
|||||||
# minute doesn't trip the past-time guard.
|
# minute doesn't trip the past-time guard.
|
||||||
if parsed_at < now_utc:
|
if parsed_at < now_utc:
|
||||||
return {"success": False, "error": "send_at must be in the future"}
|
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]
|
sid = _uuid.uuid4().hex[:16]
|
||||||
conn = sqlite3.connect(SCHEDULED_DB)
|
conn = sqlite3.connect(SCHEDULED_DB)
|
||||||
|
|||||||
100
tests/test_schedule_email_offset_normalization.py
Normal file
100
tests/test_schedule_email_offset_normalization.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user