11import re
2- from datetime import datetime , timezone
2+ from datetime import datetime , timedelta , timezone
33from uuid import UUID
44
55from loguru import logger
66from pydantic import BaseModel
7- from sqlalchemy import or_
7+ from sqlalchemy import and_ , or_
88from sqlmodel import Session , select
99
1010from auth .management import get_management_token
2323)
2424from db .setup import get_db_session
2525from db .types import GROUP_NAMES , ApprovalStatusEnum , EmailStatusEnum , GroupEnum
26+ from scheduled_tasks .email_retry import (
27+ EMAIL_JOB_ID_PREFIX ,
28+ EMAIL_MAX_ATTEMPTS ,
29+ EMAIL_QUEUE_BATCH_SIZE ,
30+ EMAIL_RETRY_WINDOW_SECONDS ,
31+ can_schedule_notification ,
32+ is_retryable_email_error ,
33+ next_retry_delay_seconds ,
34+ retry_deadline ,
35+ )
2636from scheduled_tasks .scheduler import SCHEDULER
2737from schemas .auth0 import (
2838 GROUP_ROLE_PATTERN ,
3141)
3242from schemas .biocommons import Auth0UserData
3343
34- EMAIL_QUEUE_BATCH_SIZE = 25
35- EMAIL_RETRY_DELAY_SECONDS = 300
36- EMAIL_JOB_ID_PREFIX = "email_notification_"
37-
3844
3945def _ensure_user_from_auth0 (session : Session , user_data : Auth0UserData ) -> tuple [BiocommonsUser , bool , bool ]:
4046 """
@@ -72,7 +78,6 @@ def _get_group_membership_including_deleted(session: Session, user_id: str, grou
7278
7379async def process_email_queue (
7480 batch_size : int = EMAIL_QUEUE_BATCH_SIZE ,
75- retry_delay_seconds : int = EMAIL_RETRY_DELAY_SECONDS ,
7681) -> int :
7782 """
7883 Schedule pending email notifications for delivery.
@@ -84,8 +89,12 @@ async def process_email_queue(
8489 stmt = (
8590 select (EmailNotification )
8691 .where (
87- EmailNotification .status .in_ (
88- [EmailStatusEnum .PENDING , EmailStatusEnum .FAILED ]
92+ or_ (
93+ EmailNotification .status == EmailStatusEnum .PENDING ,
94+ and_ (
95+ EmailNotification .status == EmailStatusEnum .FAILED ,
96+ EmailNotification .send_after .is_not (None ),
97+ ),
8998 ),
9099 or_ (
91100 EmailNotification .send_after .is_ (None ),
@@ -101,6 +110,15 @@ async def process_email_queue(
101110 return 0
102111 scheduled = 0
103112 for notification in notifications :
113+ if not can_schedule_notification (notification , now ):
114+ logger .info (
115+ "Skipping email %s: retry window exhausted or max attempts reached" ,
116+ notification .id ,
117+ )
118+ notification .status = EmailStatusEnum .FAILED
119+ notification .send_after = None
120+ session .add (notification )
121+ continue
104122 notification .mark_sending ()
105123 session .add (notification )
106124 session .flush ()
@@ -110,7 +128,6 @@ async def process_email_queue(
110128 args = [notification .id ],
111129 id = job_id ,
112130 replace_existing = True ,
113- kwargs = {"retry_delay_seconds" : retry_delay_seconds },
114131 )
115132 scheduled += 1
116133 session .commit ()
@@ -122,7 +139,6 @@ async def process_email_queue(
122139
123140async def send_email_notification (
124141 notification_id : UUID ,
125- retry_delay_seconds : int = EMAIL_RETRY_DELAY_SECONDS ,
126142) -> bool :
127143 """
128144 Deliver a single queued email notification.
@@ -141,10 +157,26 @@ async def send_email_notification(
141157 notification .body_html ,
142158 )
143159 except Exception as exc : # noqa: BLE001
144- logger .warning (
145- "Failed to send email %s: %s" , notification .id , exc
146- )
147- notification .mark_failed (str (exc ), retry_delay_seconds )
160+ logger .warning ("Failed to send email %s: %s" , notification .id , exc )
161+ now = datetime .now (timezone .utc )
162+ should_retry = is_retryable_email_error (exc )
163+ deadline = retry_deadline (notification )
164+ if deadline is None :
165+ deadline = now + timedelta (seconds = EMAIL_RETRY_WINDOW_SECONDS )
166+ attempts_remaining = notification .attempts < EMAIL_MAX_ATTEMPTS
167+ if (
168+ should_retry
169+ and attempts_remaining
170+ and now < deadline
171+ ):
172+ delay_seconds = next_retry_delay_seconds ()
173+ retry_time = now + timedelta (seconds = delay_seconds )
174+ if retry_time <= deadline :
175+ notification .schedule_retry (str (exc ), retry_time )
176+ session .add (notification )
177+ session .commit ()
178+ return False
179+ notification .mark_failed (str (exc ))
148180 session .add (notification )
149181 session .commit ()
150182 return False
0 commit comments