package smtpserver import ( "fmt" "mime" "net/mail" "strings" "time" "mailgoserver/internal/toolbox" ) // extractMessageID scans the raw content's header block for an existing Message-ID // header and, if its hostname doesn't match heloHostname, rewrites it to use // heloHostname — mirroring the pre-scan in smtp_handler.handle_DATA. Unlike the Python // version, a missing "@" or missing header entirely is handled explicitly instead of // crashing (the approved bug fix), by falling back to a freshly generated Message-ID. func extractMessageID(content, heloHostname string) string { for _, line := range strings.Split(content, "\n") { line = strings.TrimRight(line, "\r") if line == "" { break // end of header block } lower := strings.ToLower(line) if !strings.HasPrefix(lower, "message-id:") { continue } value := strings.TrimSpace(line[len("message-id:"):]) value = strings.Trim(value, "<>") at := strings.LastIndex(value, "@") if at < 0 { break // malformed header, no "@" — fall through to generating a fresh one } prefix, hostname := value[:at], value[at+1:] if !strings.EqualFold(hostname, heloHostname) { return fmt.Sprintf("%s@%s", prefix, heloHostname) } return value } return toolbox.GenerateMessageID(heloHostname) } // existingHeaders parses the raw header block into a lowercase-keyed map of the first // value seen per header name, folding continuation lines, mirroring the case-insensitive // existing-header lookups in _ensure_required_headers. func existingHeaders(content string) map[string]string { lines := strings.Split(content, "\n") out := map[string]string{} var lastKey string trackFold := false for _, raw := range lines { line := strings.TrimRight(raw, "\r") if line == "" { break } if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && trackFold { out[lastKey] += " " + strings.TrimSpace(line) continue } idx := strings.Index(line, ":") if idx < 0 { continue } key := strings.ToLower(strings.TrimSpace(line[:idx])) val := strings.TrimSpace(line[idx+1:]) // Unconditional out[key]=val here would keep the *last* duplicate instead of // the first (e.g. a client-supplied "Content-Type: text/html" sent alongside // swaks/library-generated "Content-Type: multipart/mixed; boundary=..." for an // attachment) — discarding the boundary and causing the raw multipart body, // left untouched below, to be delivered under the wrong Content-Type entirely. // mail.ReadMessage's Header.Get (used elsewhere, e.g. parseMessage) already // takes the first occurrence of a duplicated header, so this matches that. if _, exists := out[key]; !exists { out[key] = val lastKey = key trackFold = true } else { trackFold = false } } return out } // splitHeadersBody separates the header block from the body on the first blank line, // accepting either CRLF or bare-LF line endings (source content is LF-only from the // SMTP DATA decode; ensureRequiredHeaders' own output is CRLF). func splitHeadersBody(content string) (headerBlock, body string) { if idx := strings.Index(content, "\r\n\r\n"); idx >= 0 { return content[:idx], content[idx+4:] } if idx := strings.Index(content, "\n\n"); idx >= 0 { return content[:idx], content[idx+2:] } return content, "" } // ensureRequiredHeaders performs a full header-block *replacement* (not augmentation), // mirroring smtp_handler._ensure_required_headers exactly: a fixed, ordered whitelist // of headers is emitted, copying values from the original message where present and // defaulting where absent; anything outside that whitelist is dropped, then the // domain's custom headers plus X-Originating-IP/X-Mailer/X-Priority are appended (only // if not already present under the same name). func ensureRequiredHeaders(content, messageID string, envelopeRcptTos []string, mailFrom string, customHeaders [][2]string) string { headerBlock, body := splitHeadersBody(content) existing := existingHeaders(headerBlock) var out []string out = append(out, "Message-ID: <"+messageID+">") if v, ok := existing["date"]; ok { out = append(out, "Date: "+v) } else { out = append(out, "Date: "+time.Now().Format(time.RFC1123Z)) } if v, ok := existing["mime-version"]; ok { out = append(out, "MIME-Version: "+v) } else { out = append(out, "MIME-Version: 1.0") } if v, ok := existing["to"]; ok { out = append(out, "To: "+v) } else { out = append(out, "To: "+strings.Join(envelopeRcptTos, ", ")) } if v, ok := existing["cc"]; ok { out = append(out, "Cc: "+v) } if v, ok := existing["from"]; ok { out = append(out, "From: "+v) } else { out = append(out, "From: "+mailFrom) } if v, ok := existing["subject"]; ok { out = append(out, "Subject: "+v) } else { out = append(out, "Subject: ") } if v, ok := existing["content-type"]; ok { out = append(out, "Content-Type: "+v) } else { out = append(out, `Content-Type: text/plain; charset=UTF-8; format=flowed`) } if v, ok := existing["content-transfer-encoding"]; ok { out = append(out, "Content-Transfer-Encoding: "+v) } else { out = append(out, "Content-Transfer-Encoding: 7bit") } for _, kv := range customHeaders { if _, already := existing[strings.ToLower(kv[0])]; already { continue } out = append(out, kv[0]+": "+kv[1]) } return strings.Join(out, "\r\n") + "\r\n\r\n" + body } // getContentType mirrors smtp_handler.get_content_type: prefer the part's own type, // fall back to extension sniffing, then a small fixed extension map. func getContentType(partContentType, filename string) string { if partContentType != "" && partContentType != "application/octet-stream" { return partContentType } if guessed := mime.TypeByExtension(extOf(filename)); guessed != "" { return guessed } switch strings.ToLower(extOf(filename)) { case ".txt": return "text/plain" case ".csv": return "text/csv" case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".gif": return "image/gif" case ".pdf": return "application/pdf" case ".json": return "application/json" case ".xml": return "application/xml" case ".html", ".htm": return "text/html" default: return "application/octet-stream" } } func extOf(filename string) string { if i := strings.LastIndex(filename, "."); i >= 0 { return filename[i:] } return "" } // parseAddressList mirrors the lowercase address parsing used to classify To/Cc/Bcc. func parseAddressList(headerValue string) []string { if strings.TrimSpace(headerValue) == "" { return nil } addrs, err := mail.ParseAddressList(headerValue) if err != nil { return nil } out := make([]string, len(addrs)) for i, a := range addrs { out[i] = strings.ToLower(a.Address) } return out }