package mail import ( "fmt" "net/smtp" "strings" "time" "thuanle.me/claw-email-bridge/internal/config" "thuanle.me/claw-email-bridge/internal/database" "thuanle.me/claw-email-bridge/internal/logging" "gorm.io/gorm" ) // Retry backoff schedule: 1s, 5s, 15s. var smtpRetryBackoffs = []time.Duration{1 * time.Second, 5 * time.Second, 15 * time.Second} // SMTPSender sends reply emails via SMTP with retry logic. type SMTPSender struct { cfg *config.Config db *gorm.DB } // NewSMTPSender creates a new SMTP sender. func NewSMTPSender(cfg *config.Config, db *gorm.DB) *SMTPSender { return &SMTPSender{cfg: cfg, db: db} } // SendReply sends an AI-generated reply to the original sender. // This should be called asynchronously (go sender.SendReply(task)). func (s *SMTPSender) SendReply(task *database.Task) { log := logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID) // Build the email message with threading headers. msg := s.buildMessage(task) for attempt := 1; attempt <= len(smtpRetryBackoffs); attempt++ { task.AttemptSMTP = attempt log.Info("smtp: sending reply", "attempt", attempt, "to", task.Sender, ) err := s.send(task.Sender, msg) if err == nil { task.Status = database.StatusCompleted if err := database.UpdateTask(s.db, task); err != nil { log.Error("smtp: failed to save COMPLETED status", "error", err) } log.Info("smtp: reply sent successfully", "attempt", attempt, "status", database.StatusCompleted, ) return } log.Warn("smtp: send failed", "attempt", attempt, "error", err, ) // Update status to SMTP_RETRYING. task.Status = database.StatusSMTPRetrying if err := database.UpdateTask(s.db, task); err != nil { log.Error("smtp: failed to save retry status", "error", err) } // Last attempt — mark as FAILED. if attempt == len(smtpRetryBackoffs) { errMsg := err.Error() task.Status = database.StatusFailed task.LastError = &errMsg if err := database.UpdateTask(s.db, task); err != nil { log.Error("smtp: failed to save FAILED status", "error", err) } log.Error("smtp: all retries exhausted", "attempt", attempt, "status", database.StatusFailed, "error", errMsg, ) return } backoff := smtpRetryBackoffs[attempt-1] log.Info("smtp: retrying after backoff", "backoff", backoff) time.Sleep(backoff) } } // buildMessage constructs the RFC 2822 email with threading headers. func (s *SMTPSender) buildMessage(task *database.Task) string { subject := task.Subject if !strings.HasPrefix(strings.ToLower(subject), "re:") { subject = "Re: " + subject } var b strings.Builder b.WriteString(fmt.Sprintf("From: %s\r\n", s.cfg.SystemEmail)) b.WriteString(fmt.Sprintf("To: %s\r\n", task.Sender)) b.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) // Threading headers — required by requirements 4.3. b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", task.MessageID)) b.WriteString(fmt.Sprintf("References: %s\r\n", task.MessageID)) b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") b.WriteString("\r\n") b.WriteString(task.AIResponse) return b.String() } // send performs the actual SMTP delivery. func (s *SMTPSender) send(to string, msg string) error { addr := fmt.Sprintf("%s:%s", s.cfg.SMTPHost, s.cfg.SMTPPort) auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost) err := smtp.SendMail(addr, auth, s.cfg.SystemEmail, []string{to}, []byte(msg)) if err != nil { return fmt.Errorf("smtp sendmail: %w", err) } return nil } // SendReplyFunc returns a function compatible with api.OnCallbackDone. func (s *SMTPSender) SendReplyFunc() func(task *database.Task) { return func(task *database.Task) { s.SendReply(task) } }