Files
claw-email/internal/mail/smtp.go
T
thuanleandClaude Opus 4.7 dbe5cab56e
CI / fmt (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
ci: add Gitea Actions workflow with gofmt and test jobs
Add CI workflow at .gitea/workflows/ci.yml triggered on pull_request
with two jobs: fmt (gofmt check) and test (go test ./...).

Fix gofmt issues (import ordering) in 7 files so CI passes from
the start.

Fixes #7

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:34:56 +07:00

135 lines
3.7 KiB
Go

package mail
import (
"fmt"
"net/smtp"
"strings"
"time"
"gorm.io/gorm"
"thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database"
"thuanle.me/claw-email-bridge/internal/logging"
)
// 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)
}
}