package relay import ( "sync" "time" "mailgoserver/internal/db" ) // retrySchedule is the fixed backoff between delivery attempts (five tries total, ~17h // worst case before giving up) — fixed, not admin-configurable, matching the // auto-reply loop-prevention window's own "fixed, keeps this simple" precedent // elsewhere in this codebase. var retrySchedule = []time.Duration{time.Minute, 15 * time.Minute, time.Hour, 4 * time.Hour, 12 * time.Hour} // ProcessQueueOnce claims up to maxBatch due esrv_relay_queue rows and attempts // delivery for each with up to maxConcurrent goroutines running at once — bounded so a // large backlog can never spawn unbounded outbound connections. Meant to be called on // a ticker (see main.go's runRelayQueueWorker); a no-op when nothing is due. func (r *Relay) ProcessQueueOnce(maxConcurrent, maxBatch int) { items, err := r.DB.ClaimDueRelayQueueItems(maxBatch) if err != nil { r.Logger.Error("relay queue: claim due items: %v", err) return } if len(items) == 0 { return } sem := make(chan struct{}, maxConcurrent) var wg sync.WaitGroup for _, item := range items { wg.Add(1) sem <- struct{}{} go func(item db.RelayQueueItem) { defer wg.Done() defer func() { <-sem }() r.processQueueItem(item) }(item) } wg.Wait() } // processQueueItem attempts one recipient-domain-group's delivery via the existing // deliverToDomain (same MX-iteration/STARTTLS/MTA-STS logic RelayEmailAsync's // synchronous path already uses — this is the only new call site, not a duplicate // implementation). On success or final failure, updates the placeholder "queued" // recipient-log rows (written by Session.Data at accept time) to their real outcome // and removes the queue row. On a failure with retries remaining, reschedules instead. func (r *Relay) processQueueItem(item db.RelayQueueItem) { rcpts := make([]string, len(item.Recipients)) for i, rec := range item.Recipients { rcpts[i] = rec.Recipient } status, serverResp, errCode, errMsg, permanent := r.deliverToDomain(item.Domain, item.MailFrom, rcpts, item.Content) if status == "success" { for _, rec := range item.Recipients { if err := r.DB.UpdateEmailRecipientLogStatus(item.EmailLogID, rec.Recipient, "success", "", "", serverResp); err != nil { r.Logger.Error("relay queue: update recipient log for %s: %v", rec.Recipient, err) } } if err := r.DB.DeleteRelayQueueItem(item.ID); err != nil { r.Logger.Error("relay queue: delete item %d: %v", item.ID, err) } r.finalizeEmailLogStatusIfDone(item.EmailLogID) return } if !permanent && item.Attempts < len(retrySchedule) { next := time.Now().Add(retrySchedule[item.Attempts]) if err := r.DB.RescheduleRelayQueueItem(item.ID, next, errMsg); err != nil { r.Logger.Error("relay queue: reschedule item %d: %v", item.ID, err) } return } // Retries exhausted, or the remote server gave a permanent (RFC 5321 5xx) rejection // on the very first attempt — either way this is final. Resolve the recipient rows, // drop the queue row, and bounce back to the original sender (skipped for a null // sender, the same rule Session.Data's own immediate-bounce path already follows — // replying to a bounce is the classic loop bug). Unlike that synchronous path, // there's no live peer connection here to gate the bounce on IsIPBlacklisted — by // the time this runs, this is no longer a live mailbox-enumeration oracle the way an // instant response would be. var failedResults []Result for _, rec := range item.Recipients { if err := r.DB.UpdateEmailRecipientLogStatus(item.EmailLogID, rec.Recipient, "failed", errCode, errMsg, serverResp); err != nil { r.Logger.Error("relay queue: update recipient log for %s: %v", rec.Recipient, err) } failedResults = append(failedResults, Result{Recipient: rec.Recipient, RecipientType: rec.Type, Status: "failed", ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp}) } if err := r.DB.DeleteRelayQueueItem(item.ID); err != nil { r.Logger.Error("relay queue: delete item %d: %v", item.ID, err) } if item.MailFrom != "" { if err := r.SendBounce(item.MailFrom, "", "", failedResults); err != nil { r.Logger.Error("relay queue: send bounce to %s: %v", item.MailFrom, err) } } r.finalizeEmailLogStatusIfDone(item.EmailLogID) } // finalizeEmailLogStatusIfDone recomputes and stores the parent email_log's overall // status once every domain-group for it has resolved (no esrv_relay_queue rows left // referencing it) — recomputed from the now-current recipient-log rows via the same // overallStatus logic LogEmail used at accept time, so a message that started // "queued" naturally settles into relayed/partial/failed once nothing's left pending. func (r *Relay) finalizeEmailLogStatusIfDone(emailLogID int64) { pending, err := r.DB.CountPendingRelayQueueItemsForEmailLog(emailLogID) if err != nil { r.Logger.Error("relay queue: count pending for email_log %d: %v", emailLogID, err) return } if pending > 0 { return } recipients, err := r.DB.ListRecipientLogsForEmail(emailLogID) if err != nil { r.Logger.Error("relay queue: list recipients for email_log %d: %v", emailLogID, err) return } results := make([]Result, len(recipients)) for i, rec := range recipients { results[i] = Result{Status: rec.Status} } if err := r.DB.UpdateEmailLogStatus(emailLogID, overallStatus(results)); err != nil { r.Logger.Error("relay queue: update email_log %d status: %v", emailLogID, err) } }