-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat(workflows): email admins on AWS SES tenant reputation changes #75249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
meikelmosby
wants to merge
3
commits into
posthog-code/ses-tenant-health-ui
Choose a base branch
from
posthog-code/ses-tenant-event-comms
base: posthog-code/ses-tenant-health-ui
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
5e505de
feat(workflows): email admins on AWS SES tenant reputation changes
meikelmosby b62336f
fix(workflows): lock tenant-state transitions, notify already-bad bas…
meikelmosby c128f3d
fix(workflows): require SNS SignatureVersion 2, drop SHA1 verification
meikelmosby File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
posthog/templates/email/email_sending_reputation_finding.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| {% extends "email/base.html" %} {% load posthog_assets %} | ||
| {% block heading %}{% if high_impact %}Email sending is at risk of being paused{% else %}Reputation warning for your email sending{% endif %}{% endblock %} | ||
| {% block section %} | ||
| <!-- prettier-ignore --> | ||
| <p> | ||
| Our email provider has raised {% if high_impact %}<b>high-impact</b>{% else %}low-impact{% endif %} reputation findings against workflow email sending in project <b>{{ team }}</b>. | ||
| </p> | ||
| {% if high_impact %} | ||
| <p> | ||
| At this level, email sending for your project can be <b>paused automatically</b> to protect deliverability. Act now to avoid an interruption. | ||
| </p> | ||
| {% else %} | ||
| <p> | ||
| Sending still works, but if these findings are not fixed they can escalate and email sending for your project can be paused. | ||
| </p> | ||
| {% endif %} | ||
| <p> | ||
| Open the Reputation tab to see each finding with the provider's recommended fix, and which workflows have the highest bounce or spam complaint rates. Typical fixes: remove old or purchased addresses from your lists, and stop sending to recipients who never engage. | ||
| </p> | ||
| <div class="mb mt text-center"> | ||
| <a class="button" href="{% absolute_uri reputation_path %}">View findings</a> | ||
| </div> | ||
| <div class="mt"> | ||
| <p> | ||
| If the button above doesn't work, paste this link into your browser:<br /> | ||
| <a href="{% absolute_uri reputation_path %}">{% absolute_uri reputation_path %}</a> | ||
| </p> | ||
| </div> | ||
| {% endblock %} | ||
|
|
||
| {% block footer %} | ||
| Need help? | ||
| <a href="https://posthog.com/questions?{{ utm_tags }}" | ||
| target="_blank"><b>Contact support</b></a> | ||
| or | ||
| <a href="https://posthog.com/docs?{{ utm_tags }}" | ||
| target="_blank"><b>read our documentation</b></a>. | ||
| {% endblock %} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """ | ||
| Inbound webhook for AWS SES tenant reputation events, delivered via EventBridge → SNS HTTPS | ||
| subscription. Events are treated as change signals only — the handler never trusts the payload's | ||
| state; it enqueues a sync that reads the authoritative tenant state from the SES API. That makes | ||
| duplicate, reordered, or partially-shaped events harmless. | ||
|
|
||
| Authentication is the SNS message signature (proves "from AWS") plus a topic-ARN allowlist | ||
| (proves "from our topic"). AWS-side wiring: an EventBridge rule on `source = aws.ses` for the | ||
| tenant detail-types, targeting an SNS topic with an HTTPS subscription to this endpoint. | ||
| """ | ||
|
|
||
| import re | ||
| import json | ||
| from typing import Any | ||
|
|
||
| from django.conf import settings | ||
| from django.http import HttpRequest, HttpResponse | ||
| from django.views.decorators.csrf import csrf_exempt | ||
|
|
||
| import requests | ||
| import structlog | ||
|
|
||
| from products.workflows.backend.services.sns_verification import is_valid_sns_url, verify_sns_message | ||
| from products.workflows.backend.tasks.ses_tenant_state import sync_ses_tenant_state_task | ||
|
|
||
| logger = structlog.get_logger(__name__) | ||
|
|
||
| _TENANT_TEAM_RE = re.compile(r"\bteam-(\d+)\b") | ||
|
|
||
|
|
||
| def _extract_team_id(event: dict[str, Any]) -> int | None: | ||
| """Pull the `team-<id>` tenant name out of an EventBridge event, wherever AWS put it.""" | ||
| detail = event.get("detail") or {} | ||
| candidates: list[Any] = [ | ||
| detail.get("tenantName"), | ||
| detail.get("tenant-name"), | ||
| detail.get("reputationEntityReference"), | ||
| ] | ||
| resources = event.get("resources") | ||
| if isinstance(resources, list): | ||
| candidates.extend(resources) | ||
| for candidate in candidates: | ||
| if isinstance(candidate, str) and (match := _TENANT_TEAM_RE.search(candidate)): | ||
| return int(match.group(1)) | ||
| return None | ||
|
|
||
|
|
||
| @csrf_exempt | ||
| def ses_tenant_events_webhook(request: HttpRequest) -> HttpResponse: | ||
| """Verify an SNS delivery and enqueue a tenant-state sync for the affected team.""" | ||
| if request.method != "POST": | ||
| return HttpResponse(status=405) | ||
|
|
||
| allowed_topics = {arn for arn in settings.WORKFLOWS_SES_EVENTS_SNS_TOPIC_ARNS if arn} | ||
| if not allowed_topics: | ||
| logger.error("ses_tenant_events_webhook_not_configured") | ||
| return HttpResponse("Webhook not configured", status=500) | ||
|
|
||
| try: | ||
| # RecursionError: deeply nested JSON from an unauthenticated caller must 400, not 500. | ||
| message = json.loads(request.body) | ||
| except (json.JSONDecodeError, UnicodeDecodeError, RecursionError): | ||
| return HttpResponse("Invalid payload", status=400) | ||
| if not isinstance(message, dict): | ||
| return HttpResponse("Invalid payload", status=400) | ||
|
|
||
| if message.get("TopicArn") not in allowed_topics: | ||
| logger.warning("ses_tenant_events_webhook_unknown_topic", topic=message.get("TopicArn")) | ||
| return HttpResponse("Unknown topic", status=403) | ||
| if not verify_sns_message(message): | ||
| logger.warning("ses_tenant_events_webhook_invalid_signature", message_id=message.get("MessageId")) | ||
| return HttpResponse("Invalid signature", status=403) | ||
|
meikelmosby marked this conversation as resolved.
|
||
|
|
||
| message_type = message.get("Type") | ||
| if message_type == "SubscriptionConfirmation": | ||
| subscribe_url = message.get("SubscribeURL") | ||
| if not isinstance(subscribe_url, str) or not is_valid_sns_url(subscribe_url): | ||
| return HttpResponse("Invalid subscribe URL", status=400) | ||
| try: | ||
| requests.get(subscribe_url, timeout=5).raise_for_status() | ||
| except requests.RequestException: | ||
| # Non-2xx makes SNS retry the confirmation later. | ||
| logger.exception("ses_tenant_events_webhook_subscription_confirm_failed", topic=message.get("TopicArn")) | ||
| return HttpResponse("Subscription confirmation failed", status=502) | ||
| logger.info("ses_tenant_events_webhook_subscription_confirmed", topic=message.get("TopicArn")) | ||
| return HttpResponse(status=200) | ||
|
|
||
| if message_type != "Notification": | ||
| # UnsubscribeConfirmation and anything unknown: ack so SNS stops retrying. | ||
| return HttpResponse(status=200) | ||
|
|
||
| try: | ||
| event = json.loads(message.get("Message", "")) | ||
| except (json.JSONDecodeError, TypeError): | ||
| return HttpResponse(status=200) | ||
| if not isinstance(event, dict) or event.get("source") != "aws.ses": | ||
| return HttpResponse(status=200) | ||
|
|
||
| team_id = _extract_team_id(event) | ||
| if team_id is None: | ||
| logger.warning("ses_tenant_events_webhook_no_tenant", detail_type=event.get("detail-type")) | ||
| return HttpResponse(status=200) | ||
|
|
||
| # Ack fast; the sync fetches authoritative state and sends any transition emails. | ||
| sync_ses_tenant_state_task.delay(team_id) | ||
| return HttpResponse(status=202) | ||
27 changes: 27 additions & 0 deletions
27
products/workflows/backend/migrations/0017_teamworkflowsconfig_ses_tenant_state.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # Generated by Django 5.2.14 on 2026-07-30 08:27 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("workflows", "0016_drop_emailreputationsnapshot_fk"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="teamworkflowsconfig", | ||
| name="ses_tenant_reputation_impact", | ||
| field=models.CharField(blank=True, db_default="", default="", max_length=8), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="teamworkflowsconfig", | ||
| name="ses_tenant_sending_status", | ||
| field=models.CharField(blank=True, db_default="", default="", max_length=16), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="teamworkflowsconfig", | ||
| name="ses_tenant_state_synced_at", | ||
| field=models.DateTimeField(blank=True, null=True), | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 0016_drop_emailreputationsnapshot_fk | ||
| 0017_teamworkflowsconfig_ses_tenant_state |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
108 changes: 108 additions & 0 deletions
108
products/workflows/backend/services/ses_tenant_state.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| from collections.abc import Callable | ||
|
|
||
| from django.db import transaction | ||
| from django.utils import timezone | ||
|
|
||
| import structlog | ||
|
|
||
| from posthog.tasks.email import ( | ||
| send_email_sending_reputation_finding, | ||
| send_email_sending_suspended, | ||
| send_email_sending_unsuspended, | ||
| ) | ||
|
|
||
| from products.workflows.backend.models.team_workflows_config import TeamWorkflowsConfig | ||
| from products.workflows.backend.providers.ses import SESProvider | ||
|
|
||
| logger = structlog.get_logger(__name__) | ||
|
|
||
| PROVIDER_PAUSE_REASON = "Our email provider paused sending for this project because of high-impact reputation findings" | ||
|
|
||
| _IMPACT_SEVERITY = {"": 0, "LOW": 1, "HIGH": 2} | ||
|
meikelmosby marked this conversation as resolved.
|
||
|
|
||
| _STATE_FIELDS = ["ses_tenant_sending_status", "ses_tenant_reputation_impact", "ses_tenant_state_synced_at"] | ||
|
|
||
|
|
||
| def sync_ses_tenant_state(team_id: int) -> None: | ||
| """ | ||
| Fetch the authoritative AWS SES tenant state for a team and apply it. Called from the | ||
| EventBridge webhook (events only say "something changed" — the API is the source of truth) | ||
| and from the periodic reconciliation sweep (event delivery is best-effort). | ||
| """ | ||
| tenant = SESProvider().get_tenant_reputation(team_id) | ||
| if tenant is None: | ||
| return | ||
| apply_ses_tenant_state( | ||
| team_id, sending_status=tenant["sending_status"], reputation_impact=tenant["reputation_impact"] | ||
| ) | ||
|
|
||
|
|
||
| def apply_ses_tenant_state(team_id: int, *, sending_status: str, reputation_impact: str | None) -> None: | ||
| """ | ||
| Persist the tenant state and email the project's admins on meaningful transitions: | ||
| sending paused, sending re-enabled, or reputation findings escalating. The stored state is | ||
| the dedupe mechanism — the row is locked for the read-compare-write so overlapping syncs | ||
| (webhook + sweep, or duplicate events) serialize, and the loser sees an unchanged state. | ||
| """ | ||
| impact = reputation_impact or "" | ||
| TeamWorkflowsConfig.objects.get_or_create(team_id=team_id) | ||
|
|
||
| with transaction.atomic(): | ||
| config = TeamWorkflowsConfig.objects.select_for_update().get(team_id=team_id) | ||
| previous_status = config.ses_tenant_sending_status | ||
| previous_impact = config.ses_tenant_reputation_impact | ||
| if previous_status == sending_status and previous_impact == impact: | ||
| config.ses_tenant_state_synced_at = timezone.now() | ||
| config.save(update_fields=["ses_tenant_state_synced_at"]) | ||
| return | ||
|
|
||
| now = timezone.now() | ||
| config.ses_tenant_sending_status = sending_status | ||
| config.ses_tenant_reputation_impact = impact | ||
| config.ses_tenant_state_synced_at = now | ||
| config.save(update_fields=_STATE_FIELDS) | ||
| logger.info( | ||
| "SES tenant state changed", | ||
| team_id=team_id, | ||
| from_status=previous_status, | ||
| to_status=sending_status, | ||
| from_impact=previous_impact, | ||
| to_impact=impact, | ||
| ) | ||
|
|
||
| notify = _pick_notification( | ||
| previous_status=previous_status, | ||
| previous_impact=previous_impact, | ||
| sending_status=sending_status, | ||
| impact=impact, | ||
| team_id=team_id, | ||
| now_iso=now.isoformat(), | ||
| ) | ||
| if notify is not None: | ||
| # Dispatch after commit so a rollback can't leave an email claiming a state | ||
| # that was never persisted. | ||
| transaction.on_commit(notify) | ||
|
|
||
|
|
||
| def _pick_notification( | ||
| *, previous_status: str, previous_impact: str, sending_status: str, impact: str, team_id: int, now_iso: str | ||
| ) -> Callable[[], None] | None: | ||
| if previous_status == "": | ||
| # First sync is baseline adoption, not a transition — healthy teams stay silent so | ||
| # rollout doesn't mass-email. But a tenant that is ALREADY paused or already carries | ||
| # high-impact findings is exactly who this feature exists to tell, so those do notify. | ||
| if sending_status == "DISABLED": | ||
| return lambda: send_email_sending_suspended.delay(team_id, PROVIDER_PAUSE_REASON, now_iso) | ||
| if impact == "HIGH": | ||
| return lambda: send_email_sending_reputation_finding.delay(team_id, impact, now_iso) | ||
| return None | ||
|
|
||
| if sending_status == "DISABLED" and previous_status != "DISABLED": | ||
| return lambda: send_email_sending_suspended.delay(team_id, PROVIDER_PAUSE_REASON, now_iso) | ||
| if previous_status == "DISABLED" and sending_status in ("ENABLED", "REINSTATED"): | ||
| return lambda: send_email_sending_unsuspended.delay(team_id, now_iso) | ||
| if sending_status != "DISABLED" and _IMPACT_SEVERITY.get(impact, 0) > _IMPACT_SEVERITY.get(previous_impact, 0): | ||
| # Escalation only, and only while still sending — a paused tenant already got the | ||
| # suspension email, and de-escalations don't need action. | ||
| return lambda: send_email_sending_reputation_finding.delay(team_id, impact, now_iso) | ||
| return None | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.