Skip to content

feat: add environment prefix to email subjects#3903

Merged
hlbmtc merged 7 commits intomainfrom
claude/issue-3899-20251210-1048
Feb 18, 2026
Merged

feat: add environment prefix to email subjects#3903
hlbmtc merged 7 commits intomainfrom
claude/issue-3899-20251210-1048

Conversation

@SylvainChevalier
Copy link
Copy Markdown
Contributor

@SylvainChevalier SylvainChevalier commented Dec 10, 2025

Add [dev] or [play] prefix to email subjects when running in dev or play environments to help distinguish them from production emails.

Changes

  • Modified send_email_with_template() to add prefix based on METACULUS_ENV
  • Created get_email_subject_with_env_prefix() helper for direct EmailMessage usage
  • Applied helper to all EmailMessage instances in utils/tasks.py and misc/views.py

Fixes #3899

Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Outgoing email now prefixes subjects in dev/play, can be redirected to a single address, and will be restricted to staff/internal recipients when configured; messages with no permitted recipients are cancelled.
    • Added app startup registration so email handlers are active on launch.
  • Refactor
    • Consolidated email recipient handling and environment-specific behavior into a central startup-registered handler; removed duplicate filtering elsewhere.

Add [dev] or [play] prefix to email subjects when running in dev or play
environments to help distinguish them from production emails.

Changes:
- Modified send_email_with_template() to add prefix based on METACULUS_ENV
- Created get_email_subject_with_env_prefix() helper for direct EmailMessage usage
- Applied helper to all EmailMessage instances in utils/tasks.py and misc/views.py

Fixes #3899

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Sylvain <SylvainChevalier@users.noreply.github.com>
@SylvainChevalier
Copy link
Copy Markdown
Contributor Author

Feel free to reject if this adds too much complexity or is too hard to maintain or etc.

@hlbmtc
Copy link
Copy Markdown
Contributor

hlbmtc commented Dec 10, 2025

@claude I don't like the solution at all. Implement it globally in one place only -- send_email_async

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 10, 2025

Claude finished @hlbmtc's task in 5m 3s —— View job


Refactoring Complete ✓

I've successfully refactored the implementation to centralize all email prefix logic in misc/tasks.py as requested.

Changes Made

Single source of truth in misc/tasks.py:

  • Created add_env_prefix_to_subject() function (lines 12-17) - the single place where prefix logic lives
  • Modified send_email_async() to use this function (line 39)

Cleanup across codebase:

  • Removed duplicate prefix logic from utils/email.py
  • Removed get_email_subject_with_env_prefix() helper from utils/tasks.py
  • Updated all 6 EmailMessage instances in utils/tasks.py to use the centralized function
  • Updated both EmailMessage instances in misc/views.py to use the centralized function

Result: All email prefix logic now lives in one place (misc/tasks.py), making it easy to maintain and modify.

Tests: Verified existing tests only mock send_email_async and don't reference the old helper, so no test updates needed.


Branch: claude/issue-3899-20251210-1048

Move all environment prefix logic to a single function in misc/tasks.py:
- Created add_env_prefix_to_subject() as the single source of truth
- Updated send_email_async() to use this function
- Removed duplicate logic from utils/email.py and utils/tasks.py
- Updated all EmailMessage instances to use the centralized function

This simplifies maintenance by keeping all email prefix logic in one place.

Co-authored-by: Hlib <hlbmtc@users.noreply.github.com>
@SylvainChevalier
Copy link
Copy Markdown
Contributor Author

@claude the integration tests failed, fix the issues.

@SylvainChevalier SylvainChevalier marked this pull request as draft February 5, 2026 16:14
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 5, 2026

Claude finished @SylvainChevalier's task in 1m 32s —— View job


Test Failures Fixed ✓

Todo List:

  • Check CI status to identify failing tests
  • Analyze the test failures
  • Read relevant files to find circular import issue
  • Identify root cause
  • Fix the circular import
  • Commit and push changes

Summary:

Fixed the circular import error that was causing both unit tests and integration tests to fail.

Problem:

  • Error: ImportError: cannot import name 'User' from partially initialized module 'users.models' (most likely due to a circular import)
  • In misc/tasks.py line 7, User was imported at the module level: from users.models import User
  • This created a circular import during Django initialization

Solution:

  • Moved the User import from module level to inside the filter_staff_emails function (where it's actually used)
  • This defers the import until the function is called, avoiding the circular dependency during module initialization

Changes:

  • misc/tasks.py: Removed module-level import and added import inside filter_staff_emails function

The fix has been committed and pushed. The CI tests should now pass.


Branch: claude/issue-3899-20251210-1048 |

Move the User model import from module level to inside the
filter_staff_emails function to avoid circular import during Django
initialization. This fixes the ImportError that was causing both unit
and integration tests to fail.

Co-authored-by: Sylvain <SylvainChevalier@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 5, 2026

Warning

Rate limit exceeded

@hlbmtc has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 10 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Adds a Django AppConfig and a new Anymail/Django pre_send signal handler that prefixes subjects for dev/play, can redirect all mail, and filters recipients to staff/internal addresses before send; removes duplicate recipient-override/filter logic from task and utility layers.

Changes

Cohort / File(s) Summary
App registration
misc/apps.py
Adds MiscConfig (subclass of django.apps.AppConfig) with default_auto_field = BigAutoField, name = "misc", and ready() importing misc.signals to register handlers on startup.
Email signal handler
misc/signals.py
New pre_send handler handle_pre_send that: prefixes subject when METACULUS_ENV is dev/play, redirects recipients to SEND_ALL_MAIL_TO if set, filters recipients to staff or @metaculus.com unless EMAIL_ALLOW_SEND_TO_ALL_USERS allows all, and raises AnymailCancelSend when no recipients remain. Includes helper _filter_staff.
Removed duplicate filtering
misc/tasks.py, utils/email.py
Removed in-function recipient filtering and the SEND_ALL_MAIL_TO override from task/util layers so recipient handling is centralized in the signal handler.
Docs/guidance
Claude.md
Minor wording change encouraging top-level imports and discouraging inline imports.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor DjangoApp as "Django App"
    participant AppConfig as "misc.apps.MiscConfig"
    participant Anymail as "Anymail pre_send"
    participant SignalHandler as "misc.signals.handle_pre_send"
    participant Settings as "settings"
    participant DB as "User DB"
    participant EmailBackend as "Email backend"

    DjangoApp->>AppConfig: start
    AppConfig->>SignalHandler: import misc.signals (register handlers)
    Anymail->>SignalHandler: pre_send(message, ...)
    SignalHandler->>Settings: read METACULUS_ENV, SEND_ALL_MAIL_TO, EMAIL_ALLOW_SEND_TO_ALL_USERS
    SignalHandler->>DB: query staff emails (if filtering enabled)
    SignalHandler->>SignalHandler: prefix subject if env dev/play
    SignalHandler->>SignalHandler: replace recipients with SEND_ALL_MAIL_TO (if set)
    SignalHandler->>SignalHandler: filter recipients to staff/internal (if disallowed)
    SignalHandler->>Anymail: proceed or raise AnymailCancelSend
    Anymail->>EmailBackend: deliver message
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I hopped through settings, signals in tow,
Tagged subjects with [dev] and [play] glow,
Herded recipients safe and small,
One handler now catches them all—
Carrot-powered mail, ready to go!

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive Claude.md import style guidance change is minor and reasonable but slightly tangential to the primary objective of adding environment prefixes to email subjects. Consider clarifying whether the Claude.md change is necessary for this PR or should be addressed separately.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding environment prefixes to email subjects via signal handlers.
Linked Issues check ✅ Passed The changes implement the requirement from issue #3899 by adding [dev] or [play] prefixes to email subjects using Anymail pre-send signals.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/issue-3899-20251210-1048

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@SylvainChevalier SylvainChevalier marked this pull request as ready for review February 11, 2026 18:02
Comment thread misc/tasks.py
@hlbmtc
Copy link
Copy Markdown
Contributor

hlbmtc commented Feb 17, 2026

@claude Let's do another way around. We're currently using Anymail library for django. I see it supports pre-send signals (https://anymail.dev/en/stable/sending/signals/) which allows to modify and cancel sending emails.

Let's move prefixes logic there along with SEND_ALL_MAIL_TO and filter_staff_emails logic

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 17, 2026

Claude finished @hlbmtc's task in 5m 55s —— View job


Refactored email logic to Anymail pre-send signal

  • Gather context: read current email logic and Anymail docs
  • Create Anymail pre-send signal handler in misc/signals.py
  • Register signal handler in misc/apps.py
  • Clean up misc/tasks.py: remove add_env_prefix_to_subject, filter_staff_emails, and logic from send_email_async
  • Clean up utils/email.py: remove SEND_ALL_MAIL_TO logic
  • Clean up utils/tasks.py: remove add_env_prefix_to_subject calls
  • Clean up misc/views.py: remove add_env_prefix_to_subject import and calls
  • Commit and push changes

Summary

All email processing logic is now centralized in a single Anymail pre_send signal handler (misc/signals.py). This runs automatically for every email sent through Django's email system, regardless of origin.

New file misc/signals.py — the single handle_pre_send handler does three things:

  1. Subject prefix: Adds [dev] or [play] prefix based on METACULUS_ENV
  2. Mail redirect: Redirects all recipients to SEND_ALL_MAIL_TO when configured
  3. Staff filtering: Filters recipients to staff-only when EMAIL_ALLOW_SEND_TO_ALL_USERS is False, and cancels sending via AnymailCancelSend if no recipients remain

New file misc/apps.py — registers the signal via AppConfig.ready()

Cleanup across codebase:

  • misc/tasks.py: Removed add_env_prefix_to_subject(), filter_staff_emails(), and their usage in send_email_async()
  • utils/email.py: Removed SEND_ALL_MAIL_TO import and redirect logic
  • utils/tasks.py: Removed all add_env_prefix_to_subject() wrapper calls from 6 EmailMessage instances
  • misc/views.py: Removed add_env_prefix_to_subject import and 2 wrapper calls

View job | Branch

Centralize all email processing logic (subject prefix, SEND_ALL_MAIL_TO,
staff-only filtering) into a single Anymail pre-send signal handler.
This ensures all emails sent through Django's email system are
automatically processed regardless of where they originate.

- Create misc/signals.py with handle_pre_send signal handler
- Create misc/apps.py to register the signal in AppConfig.ready()
- Remove add_env_prefix_to_subject and filter_staff_emails from misc/tasks.py
- Remove SEND_ALL_MAIL_TO logic from utils/email.py
- Remove scattered add_env_prefix_to_subject calls from utils/tasks.py and misc/views.py

Co-authored-by: Hlib <hlbmtc@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
misc/signals.py (1)

30-47: DB query on every email send — consider caching staff emails.

User.objects.filter(is_staff=True) executes a database query for every outgoing email when EMAIL_ALLOW_SEND_TO_ALL_USERS is False. If email volume is non-trivial, this adds latency and DB load per send.

Consider caching the staff email set with a short TTL (e.g., via django.core.cache or functools.lru_cache with a time-based wrapper) so you're not hitting the DB on every single email.

♻️ Example using Django's cache framework
+from django.core.cache import cache
+
+STAFF_EMAILS_CACHE_KEY = "staff_emails_set"
+STAFF_EMAILS_CACHE_TTL = 300  # 5 minutes
+
+def _get_staff_emails() -> set[str]:
+    staff_emails = cache.get(STAFF_EMAILS_CACHE_KEY)
+    if staff_emails is None:
+        from users.models import User
+        staff_emails = set(
+            email.lower()
+            for email in User.objects.filter(is_staff=True).values_list(
+                "email", flat=True
+            )
+        )
+        cache.set(STAFF_EMAILS_CACHE_KEY, staff_emails, STAFF_EMAILS_CACHE_TTL)
+    return staff_emails

Then in the handler:

-        from users.models import User
-
-        staff_emails = set(
-            email.lower()
-            for email in User.objects.filter(is_staff=True).values_list(
-                "email", flat=True
-            )
-        )
+        staff_emails = _get_staff_emails()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@misc/signals.py` around lines 30 - 47, The code in signals.py currently
queries User.objects.filter(is_staff=True) on every send when
settings.EMAIL_ALLOW_SEND_TO_ALL_USERS is False; change this to read a cached
staff email set with a short TTL (e.g., use django.core.cache.cache.get/set with
a key like "staff_emails:set" and a TTL of e.g. 60–300s) and fall back to the DB
only on cache miss; keep using the resulting set when calling _filter_staff for
message.to/cc/bcc and raise AnymailCancelSend if none remain. Also ensure cache
is invalidated/updated when staff status changes (connect a
post_save/post_delete handler on users.models.User that clears or refreshes the
same cache key).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@misc/signals.py`:
- Around line 30-47: The code in signals.py currently queries
User.objects.filter(is_staff=True) on every send when
settings.EMAIL_ALLOW_SEND_TO_ALL_USERS is False; change this to read a cached
staff email set with a short TTL (e.g., use django.core.cache.cache.get/set with
a key like "staff_emails:set" and a TTL of e.g. 60–300s) and fall back to the DB
only on cache miss; keep using the resulting set when calling _filter_staff for
message.to/cc/bcc and raise AnymailCancelSend if none remain. Also ensure cache
is invalidated/updated when staff status changes (connect a
post_save/post_delete handler on users.models.User that clears or refreshes the
same cache key).

Comment thread misc/signals.py Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 18, 2026

Cleanup: Preview Environment Removed

The preview environment for this PR has been destroyed.

Resource Status
🌐 Preview App Deleted
🗄️ PostgreSQL Branch Deleted
⚡ Redis Database Deleted
🔧 GitHub Deployments Removed
📦 Docker Image Retained (auto-cleanup via GHCR policies)

Cleanup triggered by PR close at 2026-02-18T13:48:11Z

Copy link
Copy Markdown
Contributor

@hlbmtc hlbmtc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added extra changes and tested - it's good to go!

@hlbmtc hlbmtc merged commit 22725c3 into main Feb 18, 2026
15 checks passed
@hlbmtc hlbmtc deleted the claude/issue-3899-20251210-1048 branch February 18, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dev and play environment emails should say [dev] and [play] in the subject

2 participants