From 4a2b1e1c2cdc38d206d1c42c68b73119555c9442 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sun, 22 Feb 2026 10:46:07 -0700 Subject: [PATCH 1/3] Add Resend List Emails API for recipient-based email status lookup Adds a new endpoint GET /api/admin/emails/resend-list that fetches all sent emails from Resend's List Emails API, indexes them by recipient email address, and caches the result in Redis (300s TTL). This removes the dependency on stored resend_id values for showing delivery status. Also invalidates the cache after sending new emails. Co-Authored-By: Claude Opus 4.6 --- api/volunteers/volunteers_views.py | 25 ++++++ services/volunteers_service.py | 136 ++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index 163b44e..a2a8cfe 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -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 @@ -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)}") diff --git a/services/volunteers_service.py b/services/volunteers_service.py index a8089fc..5a7b734 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -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 @@ -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, @@ -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, @@ -1989,3 +1997,129 @@ 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() + all_emails = [] + max_pages = 10 + + if not hasattr(resend.Emails, 'list'): + error(logger, "resend.Emails.list is not available in this SDK version") + return {'success': False, 'error': 'resend.Emails.list not available'} + + for page in range(max_pages): + try: + # Resend SDK list() returns a ListEmailsResponse + # The response has a 'data' attribute with the email list + response = resend.Emails.list() + email_list = [] + if isinstance(response, dict): + email_list = response.get('data', []) + elif hasattr(response, 'data'): + email_list = response.data if isinstance(response.data, list) else [] + else: + email_list = list(response) if response else [] + + all_emails.extend(email_list) + info(logger, "Fetched Resend emails page", + page=page, count=len(email_list), total_so_far=len(all_emails)) + + # Resend list API returns all emails in one call (no pagination params) + # so we only need one iteration + break + + except Exception as page_error: + warning(logger, "Error fetching Resend emails page", + page=page, exc_info=page_error) + break + + # 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 + set_cached(cache_key, { + 'emails_by_recipient': index, + 'total_fetched': total_fetched + }, ttl=300) + + 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)} From de319b59a9c9442c2e89a115d65fb90d624290e6 Mon Sep 17 00:00:00 2001 From: Greg V Date: Mon, 23 Feb 2026 08:11:17 -0700 Subject: [PATCH 2/3] Upgrade resend SDK to 2.22.0 and use Emails.list() natively The installed resend 2.3.0 didn't have Emails.list(). Upgraded to 2.22.0 which supports list() with pagination (limit, after cursor, has_more). Co-Authored-By: Claude Opus 4.6 --- requirements.txt | 2 +- services/volunteers_service.py | 33 ++++++++++++++------------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2b74c2f..2e6d459 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 5a7b734..32bfae8 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -2031,34 +2031,29 @@ def list_all_resend_emails(filter_emails=None): 'from_cache': True } - # Paginate through Resend Emails.list() + # Paginate through resend.Emails.list() (requires resend >= 2.5) all_emails = [] max_pages = 10 - - if not hasattr(resend.Emails, 'list'): - error(logger, "resend.Emails.list is not available in this SDK version") - return {'success': False, 'error': 'resend.Emails.list not available'} + params = {"limit": 100} for page in range(max_pages): try: - # Resend SDK list() returns a ListEmailsResponse - # The response has a 'data' attribute with the email list - response = resend.Emails.list() - email_list = [] - if isinstance(response, dict): - email_list = response.get('data', []) - elif hasattr(response, 'data'): - email_list = response.data if isinstance(response.data, list) else [] - else: - email_list = list(response) if response else [] - + 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)) - # Resend list API returns all emails in one call (no pagination params) - # so we only need one iteration - break + 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", From 7464ee6866b428ffe8e377f35bbfdde4b4967d8f Mon Sep 17 00:00:00 2001 From: Greg V Date: Tue, 24 Feb 2026 09:16:32 -0700 Subject: [PATCH 3/3] Skip caching empty Resend email results Prevents stale empty results from being served when a previous fetch failed (e.g., due to old SDK version). Co-Authored-By: Claude Opus 4.6 --- services/volunteers_service.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 32bfae8..ed5e3d0 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -2095,10 +2095,12 @@ def list_all_resend_emails(filter_emails=None): total_fetched = len(all_emails) # Cache the full index with 300s TTL - set_cached(cache_key, { - 'emails_by_recipient': index, - 'total_fetched': total_fetched - }, ttl=300) + # 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) info(logger, "Built Resend email index", total_fetched=total_fetched, unique_recipients=len(index))