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/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 a8089fc..ed5e3d0 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,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} + + 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 + + # 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) + + 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)}