diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 4855a9ec9..116478d5c 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, @@ -46,15 +48,27 @@ _ = (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) +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 tracked_send: SendEmailResult = emails.send(to="user@example.com", template_key="welcome", subject="Welcome", html="

Hi

", tracking={"clicks": True, "engagement": False}) untracked_send: SendEmailResult = emails.send(to="user@example.com", template_key="welcome", subject="Welcome", html="

Hi

", tracking=False) not_sensitive_send: SendEmailResult = emails.send(to="user@example.com", template_key="newsletter", subject="News", html="

News

", sensitive=False) -_ = (template_send, rendered_send, tracked_send, untracked_send) +_ = (template_send, rendered_send, tracked_send, untracked_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( @@ -114,6 +128,11 @@ Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3 Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", tracking={"open": 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 +Emails("phs_example").list(status="sent") # 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 2839a2dd6..e6073aa6f 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="