build-base
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/delivery"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
||||
)
|
||||
|
||||
// QueueWorker polls the delivery queue and dispatches messages.
|
||||
@@ -106,6 +113,7 @@ func (w *QueueWorker) drainQueue() {
|
||||
}
|
||||
|
||||
// markFailed updates queue status with exponential back-off or marks permanent failure.
|
||||
// On permanent failure with a non-empty sender, generates a DSN (RFC 3464) bounce.
|
||||
func (w *QueueWorker) markFailed(ctx context.Context, queueID int64, from, to, errMsg string, perm bool) {
|
||||
status := "failed"
|
||||
if perm {
|
||||
@@ -125,6 +133,135 @@ func (w *QueueWorker) markFailed(ctx context.Context, queueID int64, from, to, e
|
||||
}
|
||||
w.deps.DB.LogDelivery(ctx, queueID, from, to, status, 0, errMsg, "") //nolint:errcheck
|
||||
log.Printf("[queue] %s %d → %s: %s", status, queueID, to, errMsg)
|
||||
|
||||
// Generate DSN bounce for permanent failures only, never bounce a bounce
|
||||
// (null sender <> = already a DSN).
|
||||
if perm && from != "" && from != "<>" {
|
||||
w.sendDSN(ctx, from, to, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// sendDSN delivers a Delivery Status Notification (RFC 3464) to the original sender.
|
||||
// Failures here are logged but not re-queued to avoid bounce loops.
|
||||
func (w *QueueWorker) sendDSN(ctx context.Context, originalFrom, failedTo, reason string) {
|
||||
sender := strings.ToLower(originalFrom)
|
||||
|
||||
// Determine if original sender is a local user.
|
||||
user, err := w.deps.DB.GetUserByEmail(ctx, sender)
|
||||
if err != nil {
|
||||
log.Printf("[queue] dsn lookup sender %s: %v", sender, err)
|
||||
return
|
||||
}
|
||||
if user == nil || !user.Enabled {
|
||||
// Sender is remote — attempt external SMTP delivery of DSN.
|
||||
dsnRaw, buildErr := buildDSN(w.deps.Cfg.Hostname, failedTo, reason)
|
||||
if buildErr != nil {
|
||||
log.Printf("[queue] dsn build: %v", buildErr)
|
||||
return
|
||||
}
|
||||
ehlo := w.deps.Cfg.SMTPHostname
|
||||
if ehlo == "" {
|
||||
ehlo = w.deps.Cfg.Hostname
|
||||
}
|
||||
result := delivery.Deliver(ctx, ehlo, "", sender, dsnRaw)
|
||||
if result.SMTPCode != 250 {
|
||||
log.Printf("[queue] dsn delivery to %s failed: %s", sender, result.Message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Local sender — save DSN directly to INBOX.
|
||||
inbox, err := w.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxInbox)
|
||||
if err != nil || inbox == nil {
|
||||
log.Printf("[queue] dsn inbox for %s: %v", sender, err)
|
||||
return
|
||||
}
|
||||
|
||||
dsnRaw, err := buildDSN(w.deps.Cfg.Hostname, failedTo, reason)
|
||||
if err != nil {
|
||||
log.Printf("[queue] dsn build: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
hostname := w.deps.Cfg.Hostname
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
msg := &storage.IncomingMessage{
|
||||
Raw: dsnRaw,
|
||||
FromEmail: "",
|
||||
Subject: "Delivery Status Notification: failed to deliver to " + failedTo,
|
||||
Date: time.Now().UTC(),
|
||||
MessageID: fmt.Sprintf("<dsn-%d@%s>", time.Now().UnixNano(), hostname),
|
||||
}
|
||||
if _, err := w.deps.Store.SaveIncoming(ctx, user.ID, inbox.ID, msg); err != nil {
|
||||
log.Printf("[queue] dsn save: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDSN constructs a minimal RFC 3464 multipart/report message.
|
||||
func buildDSN(hostname, failedTo, reason string) ([]byte, error) {
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
msgID := fmt.Sprintf("<dsn-%d@%s>", now.UnixNano(), hostname)
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
boundary := mw.Boundary()
|
||||
|
||||
// Outer headers.
|
||||
header := fmt.Sprintf(
|
||||
"From: Mail Delivery Subsystem <mailer-daemon@%s>\r\n"+
|
||||
"To: <%s>\r\n"+
|
||||
"Subject: Delivery Status Notification (Failure)\r\n"+
|
||||
"Date: %s\r\n"+
|
||||
"Message-ID: %s\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: multipart/report; report-type=delivery-status; boundary=%q\r\n"+
|
||||
"\r\n",
|
||||
hostname, failedTo,
|
||||
now.Format("Mon, 02 Jan 2006 15:04:05 -0700"),
|
||||
msgID, boundary,
|
||||
)
|
||||
buf.WriteString(header)
|
||||
|
||||
// Part 1: human-readable explanation.
|
||||
ph := make(textproto.MIMEHeader)
|
||||
ph.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
pw, err := mw.CreatePart(ph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(pw,
|
||||
"Your message could not be delivered to the following recipient:\r\n\r\n"+
|
||||
" Recipient: %s\r\n"+
|
||||
" Reason: %s\r\n\r\n"+
|
||||
"This is a permanent error. The message has not been delivered and will not be retried.\r\n",
|
||||
failedTo, reason)
|
||||
|
||||
// Part 2: machine-readable delivery-status (RFC 3464).
|
||||
sh := make(textproto.MIMEHeader)
|
||||
sh.Set("Content-Type", "message/delivery-status")
|
||||
sw, err := mw.CreatePart(sh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(sw,
|
||||
"Reporting-MTA: dns; %s\r\n\r\n"+
|
||||
"Final-Recipient: rfc822; %s\r\n"+
|
||||
"Action: failed\r\n"+
|
||||
"Status: 5.0.0\r\n"+
|
||||
"Diagnostic-Code: smtp; %s\r\n",
|
||||
hostname, failedTo, reason)
|
||||
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// nextBackoff returns the back-off duration based on attempt count using
|
||||
|
||||
Reference in New Issue
Block a user