package smtpserver import ( "bytes" "encoding/base64" "io" "mime" "mime/multipart" "net/mail" "path/filepath" "strings" "time" ) // attachmentStoragePath mirrors smtp_handler.get_attachment_storage_path: // {base}/{safe_domain}/{username_or_ip}/{YYYY-DD-MMM}/ func attachmentStoragePath(base, domain, usernameOrIP string, now time.Time) string { safeDomain := sanitizePathSegment(domain, "/\\") dateFolder := now.Format("2006-02-Jan") parts := []string{base, safeDomain} if usernameOrIP != "" { parts = append(parts, usernameOrIP) } parts = append(parts, dateFolder) return filepath.Join(parts...) } func sanitizePathSegment(s string, chars string) string { for _, c := range chars { s = strings.ReplaceAll(s, string(c), "_") } return s } // cleanMessageIDPrefix strips everything from "@" onward, mirroring the // clean_message_id computation used to build attachment filenames. func cleanMessageIDPrefix(messageID string) string { if i := strings.Index(messageID, "@"); i >= 0 { return messageID[:i] } return messageID } type attachmentPart struct { Filename string ContentType string Data []byte } type parsedMessage struct { HeaderLines []string // "Name: value" per header, in order BodyText string // concatenated text/* parts Attachments []attachmentPart } // parseMessage mirrors the repeated BytesParser(policy=policy.default) passes in // handle_DATA: it extracts header lines for logging, concatenated text body, and any // attachment parts (Content-Disposition: attachment with a filename). func parseMessage(raw []byte) (*parsedMessage, error) { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return nil, err } out := &parsedMessage{} for k, vs := range msg.Header { for _, v := range vs { out.HeaderLines = append(out.HeaderLines, k+": "+v) } } contentType := msg.Header.Get("Content-Type") mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { mediaType = "text/plain" } if strings.HasPrefix(mediaType, "multipart/") { mr := multipart.NewReader(msg.Body, params["boundary"]) for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { break } data, _ := io.ReadAll(part) // multipart.Reader auto-decodes quoted-printable transparently during Read, // but not base64 (see the mime/multipart docs) — without this, a base64 // attachment/body part is stored/relayed-for-display as raw base64 text // instead of its actual decoded bytes. data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data) disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition")) partCT := part.Header.Get("Content-Type") partMediaType, _, _ := mime.ParseMediaType(partCT) if disp == "attachment" && dispParams["filename"] != "" { out.Attachments = append(out.Attachments, attachmentPart{ Filename: dispParams["filename"], ContentType: getContentType(partMediaType, dispParams["filename"]), Data: data, }) continue } if strings.HasPrefix(partMediaType, "text/") && disp != "attachment" { out.BodyText += string(data) + "\n" } } } else if strings.HasPrefix(mediaType, "text/") { data, _ := io.ReadAll(msg.Body) out.BodyText = string(data) } out.BodyText = strings.TrimSpace(out.BodyText) return out, nil } // decodeContentTransferEncoding decodes a MIME part's body per its // Content-Transfer-Encoding when that isn't already handled transparently by // multipart.Reader (which only auto-decodes quoted-printable). base64 bodies are // wrapped at a fixed line length, so whitespace/newlines are stripped before // decoding. Falls back to the raw bytes on a decode error or any other encoding // (7bit/8bit/binary need no transform). func decodeContentTransferEncoding(cte string, data []byte) []byte { if !strings.EqualFold(strings.TrimSpace(cte), "base64") { return data } cleaned := make([]byte, 0, len(data)) for _, b := range data { switch b { case ' ', '\t', '\r', '\n': continue default: cleaned = append(cleaned, b) } } decoded, err := base64.StdEncoding.DecodeString(string(cleaned)) if err != nil { return data } return decoded }