From d500b2f387eafdf2862ed448a504fce423a729e8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:37:56 +0000 Subject: [PATCH 1/6] feat(emails): schedule, move, cancel and list emails send() takes scheduled_at, an aware datetime or an ISO 8601 string with an offset, and refuses a naive datetime before any request. Emails and AsyncEmails gain update(), cancel() and list(). Cancel and update retry like a send; list never retries. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 26 +- .sampo/changesets/scheduled-email-sends.md | 5 + posthog/emails.py | 445 ++++++++++++++++++++- posthog/test/test_emails.py | 415 ++++++++++++++++++- references/public_api_snapshot.txt | 25 +- 5 files changed, 904 insertions(+), 12 deletions(-) create mode 100644 .sampo/changesets/scheduled-email-sends.md diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 4e41c9e8f..13cc955b5 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -15,7 +15,9 @@ import atexit import posthog from posthog import FeatureFlagEvaluations, FlagValue, Posthog -from posthog.emails import AsyncEmails, Emails, SendEmailResult +from datetime import datetime, timezone + +from posthog.emails import AsyncEmails, CancelEmailResult, EmailList, Emails, SendEmailResult, UpdateEmailResult from posthog.ai.evaluations import ( AsyncOfflineEvaluations, BooleanScorerConfig, @@ -44,12 +46,24 @@ _ = (flag_value, all_flags, enabled, payload, evaluations, active) emails = Emails("phs_example") template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}) rendered_send: SendEmailResult = emails.send(to="user@example.com", template_key="password-reset", subject="Reset", html="

Reset

", from_="Acme ", reply_to=["help@example.com"], capture_engagement=False, max_retries=0) -_ = (template_send, rendered_send) +scheduled_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", scheduled_at=datetime(2026, 12, 1, tzinfo=timezone.utc)) +scheduled_status: str = scheduled_send.status +scheduled_time: datetime | None = scheduled_send.scheduled_at +moved: UpdateEmailResult = emails.update(scheduled_send.id, scheduled_at="2026-12-02T09:00:00+02:00") +canceled: CancelEmailResult = emails.cancel(scheduled_send.id) +page: EmailList = emails.list(cursor=None, limit=50) +next_page: EmailList | None = emails.list(cursor=page.next) if page.next else None +first_time: datetime = page.results[0].scheduled_at +_ = (template_send, rendered_send, scheduled_status, scheduled_time, moved, canceled, next_page, first_time) async def async_email_types() -> None: - sent: SendEmailResult = await AsyncEmails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

") - _ = sent + async_emails = AsyncEmails("phs_example") + sent: SendEmailResult = await async_emails.send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", scheduled_at="2026-12-01T09:00:00Z") + moved: UpdateEmailResult = await async_emails.update(sent.id, scheduled_at=datetime(2026, 12, 2, tzinfo=timezone.utc)) + canceled: CancelEmailResult = await async_emails.cancel(sent.id) + page: EmailList = await async_emails.list() + _ = (sent, moved, canceled, page) async def offline_evaluation_types( @@ -91,6 +105,10 @@ Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3 Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", capture_engagement=False) # expected-type-error Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", template_data={"name": "Ada"}) # expected-type-error Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", cc="team@example.com") # expected-type-error +Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", scheduled_at=1796115600) # expected-type-error +Emails("phs_example").update("0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b") # expected-type-error +Emails("phs_example").update("0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", "2026-12-01T09:00:00Z") # expected-type-error +Emails("phs_example").list("scheduled") # expected-type-error async def invalid_async_email() -> None: diff --git a/.sampo/changesets/scheduled-email-sends.md b/.sampo/changesets/scheduled-email-sends.md new file mode 100644 index 000000000..c0170b3c2 --- /dev/null +++ b/.sampo/changesets/scheduled-email-sends.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Schedule a transactional email with `scheduled_at` on `emails.send()`, up to 30 days ahead, and move, cancel or list scheduled emails with `emails.update()`, `emails.cancel()` and `emails.list()`. A naive `datetime` raises `ValueError` before any request. Send results now carry `status` and `scheduled_at`. diff --git a/posthog/emails.py b/posthog/emails.py index 42e70b971..045a5d949 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -71,12 +71,29 @@ - ``client_disabled``: the client is disabled, so it sends nothing. - ``client_closed``: the client is shut down. -Status: ``emails.get(message_id)`` reads what happened to a sent email, for +Status: ``emails.get(message_id)`` reads what happened to an email, for 30 days after the send, as an :class:`EmailStatus`. A key with only ``transactional_email:read`` can read statuses but not send. ``get`` raises ``not_found`` (404) for an unknown ID, an email older than 30 days, or for a few seconds after the send. +Scheduling: pass ``scheduled_at``, an aware ``datetime`` or an ISO 8601 +string with an offset, to send up to 30 days later. The result's ``status`` is +``scheduled``. Until the email goes out, ``emails.list()`` finds it, +``emails.update(id, scheduled_at=...)`` moves it, and ``emails.cancel(id)`` +stops it. A naive ``datetime`` raises ``ValueError`` before any request. + +```python +from datetime import datetime, timedelta, timezone + +result = posthog.emails.send( + to="user@example.com", + template_id="