package relay import ( "fmt" "strings" "time" "mailgoserver/internal/mailstore" "mailgoserver/internal/toolbox" ) // buildBounceMessage renders a simplified delivery-status notification: plain // text/plain, not the full RFC 3464 multipart/report shape (a machine-readable // message/delivery-status part), since every real mail client just shows the // human-readable part anyway and this avoids a second MIME structure to maintain for // something informational, not delivery-critical. func buildBounceMessage(hostname, to, originalSubject, originalMessageID string, failed []Result) (content, messageID string) { messageID = toolbox.GenerateMessageID(hostname) var body strings.Builder body.WriteString("This is an automatically generated Delivery Status Notification.\r\n\r\n") body.WriteString("Delivery to the following recipient(s) failed permanently:\r\n\r\n") for _, f := range failed { reason := f.ErrorMessage if reason == "" { reason = f.ServerResponse } if reason == "" { reason = "unknown error" } body.WriteString(fmt.Sprintf(" - %s\r\n", f.Recipient)) if f.ErrorCode != "" { body.WriteString(fmt.Sprintf(" Reason: %s (%s)\r\n\r\n", reason, f.ErrorCode)) } else { body.WriteString(fmt.Sprintf(" Reason: %s\r\n\r\n", reason)) } } body.WriteString("----- Original message -----\r\n") if originalSubject != "" { body.WriteString("Subject: " + originalSubject + "\r\n") } if originalMessageID != "" { body.WriteString("Message-ID: <" + originalMessageID + ">\r\n") } body.WriteString("\r\nThis is an automated message from " + hostname + " — please do not reply.\r\n") headers := []string{ "Message-ID: <" + messageID + ">", "Date: " + time.Now().Format(time.RFC1123Z), "From: Mail Delivery System ", "To: " + to, "Subject: Undelivered Mail Returned to Sender", // Marks this as an automated notification (RFC 3834) so any auto-responder or // bounce-of-a-bounce logic on the receiving end knows not to reply to it — // same anti-loop convention SendBounce itself relies on for its own delivery. "Auto-Submitted: auto-replied", `Content-Type: text/plain; charset="UTF-8"`, "Content-Transfer-Encoding: 8bit", "MIME-Version: 1.0", } return strings.Join(headers, "\r\n") + "\r\n\r\n" + body.String(), messageID } // SendBounce notifies to that delivery failed for the recipients in failed, mirroring // what a real MTA does when it can't express "delivered to some, not others" as a // single SMTP response and has to accept-then-notify instead of reject-and-let-the- // client's-own-MTA-bounce-it. A no-op if to is empty (this itself would be responding // to a null-sender/already-bounced message — replying to those is the classic bounce- // loop bug, so it's refused unconditionally, not left to the caller to remember) or if // there's nothing failed to report. // // Delivery is local (straight into to's own INBOX, no SMTP round-trip) when to // resolves to a mailbox this server hosts, otherwise it's relayed out exactly like any // other outbound message — using a null reverse-path (empty MAIL FROM, i.e. the wire // form "MAIL FROM:<>") so a failure bouncing the bounce itself can never recurse. func (r *Relay) SendBounce(to, originalSubject, originalMessageID string, failed []Result) error { if to == "" || len(failed) == 0 { return nil } content, bounceMessageID := buildBounceMessage(r.Hostname, to, originalSubject, originalMessageID, failed) if r.Mailstore != nil { if mbox, err := r.Mailstore.ResolveRecipient(to); err == nil && mbox != nil { _, err := r.Mailstore.StoreMessage(mbox.ID, "INBOX", []byte(content), bounceMessageID, "Mail Delivery System ", "Undelivered Mail Returned to Sender") if err == mailstore.ErrQuotaExceeded { // Nothing sensible to do — the mailbox that would receive the bounce // is itself over quota. Drop it; the sender already saw an inline // error (webmail) or their own MTA is retrying (SMTP), so this isn't // the only signal they have. return nil } return err } } results := r.RelayEmailAsync("", []string{to}, content, []string{"to"}) if len(results) > 0 && results[0].Status != "success" { return fmt.Errorf("bounce to %s: %s", to, results[0].ErrorMessage) } return nil }