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>
135 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|