Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions api/volunteers/volunteers_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
send_volunteer_message,
send_email_to_address,
get_resend_email_statuses,
list_all_resend_emails,
)
from common.auth import auth, auth_user

Expand Down Expand Up @@ -656,3 +657,27 @@ def admin_get_resend_email_statuses():
logger.error("Error in admin_get_resend_email_statuses: %s", str(e))
logger.exception(e)
return _error_response(f"Failed to fetch email statuses: {str(e)}")


@bp.route('/admin/emails/resend-list', methods=['GET'])
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_list_resend_emails():
"""Admin endpoint to list all sent emails from Resend, indexed by recipient."""
try:
emails_param = request.args.get('emails', '')
filter_emails = [e.strip() for e in emails_param.split(',') if e.strip()] if emails_param else None

logger.info("Listing Resend emails, filter_count=%d", len(filter_emails) if filter_emails else 0)

result = list_all_resend_emails(filter_emails=filter_emails)

if result['success']:
return _success_response(result, "Resend email list fetched successfully")

logger.error("Failed to list Resend emails. Error: %s", result.get('error', 'Unknown error'))
return _error_response(result.get('error', 'Unknown error'), 500)

except Exception as e:
logger.error("Error in admin_list_resend_emails: %s", str(e))
logger.exception(e)
return _error_response(f"Failed to list Resend emails: {str(e)}")
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ et-xmlfile==1.1.0
openpyxl==3.1.2
pylint==3.2.5
pytest==8.2.2
resend==2.3.0
resend==2.22.0
readme-metrics[Flask]==3.1.0
redis>=6.1.0
tiktoken==0.9.0
Expand Down
133 changes: 132 additions & 1 deletion services/volunteers_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from google.cloud import firestore
from common.utils.slack import get_slack_user_by_email, send_slack
from common.log import get_logger, info, debug, warning, error, exception
from common.utils.redis_cache import redis_cached, delete_cached, clear_pattern
from common.utils.redis_cache import redis_cached, delete_cached, clear_pattern, get_cached, set_cached
from common.utils.oauth_providers import SLACK_PREFIX, normalize_slack_user_id, is_oauth_user_id, is_slack_user_id, extract_slack_user_id
import os
import requests
Expand Down Expand Up @@ -1791,6 +1791,10 @@ def send_volunteer_message(
# Determine success based on whether at least one message was sent
success = delivery_status['slack_sent'] or delivery_status['email_sent']

# Invalidate Resend email list cache so next fetch picks up the new email
if delivery_status['email_sent']:
delete_cached("resend:all_emails_index")

result = {
'success': success,
'volunteer_id': volunteer_id,
Expand Down Expand Up @@ -1925,6 +1929,10 @@ def send_email_to_address(
}
)

# Invalidate Resend email list cache so next fetch picks up the new email
if email_success:
delete_cached("resend:all_emails_index")

result = {
'success': email_success,
'recipient_email': email,
Expand Down Expand Up @@ -1989,3 +1997,126 @@ def get_resend_email_statuses(email_ids: list) -> Dict[str, Any]:
except Exception as e:
error(logger, "Error fetching Resend email statuses", exc_info=e)
return {'success': False, 'error': str(e)}


def list_all_resend_emails(filter_emails=None):
"""
Fetch all sent emails from Resend's List Emails API, cached in Redis.
Returns an index of {recipient_email: [{id, subject, created_at, last_event}, ...]}.

Args:
filter_emails: Optional list of email addresses to filter results for.

Returns:
Dict with 'success', 'emails_by_recipient', and 'total_fetched'.
"""
try:
resend.api_key = os.environ.get('RESEND_EMAIL_STATUS_KEY')
if not resend.api_key:
return {'success': False, 'error': 'Resend API key not configured'}

cache_key = "resend:all_emails_index"
cached = get_cached(cache_key)
if cached is not None:
info(logger, "Using cached Resend email index",
total_emails=cached.get('total_fetched', 0))
index = cached['emails_by_recipient']
if filter_emails:
filter_set = {e.lower() for e in filter_emails}
index = {k: v for k, v in index.items() if k in filter_set}
return {
'success': True,
'emails_by_recipient': index,
'total_fetched': cached['total_fetched'],
'from_cache': True
}

# Paginate through resend.Emails.list() (requires resend >= 2.5)
all_emails = []
max_pages = 10
params = {"limit": 100}

Comment on lines +2034 to +2038
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

list_all_resend_emails() caps pagination at max_pages = 10 (max ~1000 emails) but the function name/description and response fields (total_fetched) suggest a complete list. This can silently truncate results for accounts with more history. Consider either paginating until has_more is false (with a configurable safety cap) or returning an explicit truncated/pages_fetched flag and documenting the limit.

Copilot uses AI. Check for mistakes.
for page in range(max_pages):
try:
response = resend.Emails.list(params)
email_list = response.get('data', []) if isinstance(response, dict) else getattr(response, 'data', [])
all_emails.extend(email_list)
info(logger, "Fetched Resend emails page",
page=page, count=len(email_list), total_so_far=len(all_emails))

has_more = response.get('has_more', False) if isinstance(response, dict) else getattr(response, 'has_more', False)
if not has_more or len(email_list) == 0:
break

# Use last email ID as cursor for next page
last_item = email_list[-1]
last_id = last_item.get('id', '') if isinstance(last_item, dict) else getattr(last_item, 'id', '')
if not last_id:
break
params = {"limit": 100, "after": last_id}

except Exception as page_error:
warning(logger, "Error fetching Resend emails page",
page=page, exc_info=page_error)
break
Comment on lines +2058 to +2061
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

If a page fetch fails, the loop breaks and the function proceeds to build/cache an index from whatever was fetched so far, while still returning success: True. That can cache and serve partial/stale data for 5 minutes. Consider treating page fetch errors as success: False, or at least avoid caching when a page error occurred and include an incomplete flag in the response.

Copilot uses AI. Check for mistakes.

# Build index by recipient email
index = {}
for email_data in all_emails:
if isinstance(email_data, dict):
recipients = email_data.get('to', [])
email_id = email_data.get('id', '')
subject = email_data.get('subject', '')
created_at = email_data.get('created_at', '')
last_event = email_data.get('last_event', '')
else:
recipients = getattr(email_data, 'to', [])
email_id = getattr(email_data, 'id', '')
subject = getattr(email_data, 'subject', '')
created_at = getattr(email_data, 'created_at', '')
last_event = getattr(email_data, 'last_event', '')

if isinstance(recipients, str):
recipients = [recipients]

entry = {
'id': email_id,
'subject': subject,
'created_at': created_at,
'last_event': last_event,
}

for recipient in recipients:
key = recipient.lower().strip()
if key not in index:
index[key] = []
index[key].append(entry)

total_fetched = len(all_emails)

# Cache the full index with 300s TTL
# Only cache if we actually fetched emails (avoid caching errors/empty results)
if total_fetched > 0:
set_cached(cache_key, {
'emails_by_recipient': index,
'total_fetched': total_fetched
}, ttl=300)
Comment on lines +2097 to +2103
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The cache is only written when total_fetched > 0. If the Resend account has zero emails (or the filter returns none), this will re-hit the Resend API on every request until an email exists. Consider caching the empty index as a valid result (with the same TTL) to protect the Resend API and reduce latency.

Suggested change
# Cache the full index with 300s TTL
# Only cache if we actually fetched emails (avoid caching errors/empty results)
if total_fetched > 0:
set_cached(cache_key, {
'emails_by_recipient': index,
'total_fetched': total_fetched
}, ttl=300)
# Cache the full index with 300s TTL, including empty results
set_cached(cache_key, {
'emails_by_recipient': index,
'total_fetched': total_fetched
}, ttl=300)

Copilot uses AI. Check for mistakes.

info(logger, "Built Resend email index",
total_fetched=total_fetched, unique_recipients=len(index))

# Filter if requested
if filter_emails:
filter_set = {e.lower() for e in filter_emails}
index = {k: v for k, v in index.items() if k in filter_set}

return {
'success': True,
'emails_by_recipient': index,
'total_fetched': total_fetched,
'from_cache': False
}

except Exception as e:
error(logger, "Error listing Resend emails", exc_info=e)
return {'success': False, 'error': str(e)}