// Package compose builds standards-based MIME bodies for Usenet posts. package compose import ( "crypto/rand" "encoding/base64" "errors" "fmt" "mime" "mime/multipart" "net/textproto" "path/filepath" "strings" "unicode/utf8" ) const maxAttachmentBytes = 8 << 20 type Attachment struct { Filename string ContentType string Data []byte } func BuildMixed(text string, attachments []Attachment) (contentType, transferEncoding, body string, err error) { if !utf8.ValidString(text) { return "", "", "", errors.New("MIME text is not valid UTF-8") } for _, attachment := range attachments { if strings.TrimSpace(attachment.Filename) == "" { return "", "", "", errors.New("attachment filename is required") } if strings.ContainsAny(attachment.Filename, "\r\n") || len(attachment.Data) > maxAttachmentBytes { return "", "", "", errors.New("attachment filename or size is invalid") } } boundary, err := boundary() if err != nil { return "", "", "", err } var builder strings.Builder writer := multipart.NewWriter(&builder) if err := writer.SetBoundary(boundary); err != nil { return "", "", "", err } textHeader := make(textproto.MIMEHeader) textHeader.Set("Content-Type", "text/plain; charset=UTF-8") textHeader.Set("Content-Transfer-Encoding", "8bit") part, err := writer.CreatePart(textHeader) if err != nil { return "", "", "", err } if _, err := part.Write([]byte(normalizeCRLF(text))); err != nil { return "", "", "", err } for _, attachment := range attachments { header := make(textproto.MIMEHeader) contentType := strings.TrimSpace(attachment.ContentType) if contentType == "" { contentType = "application/octet-stream" } if _, _, err := mime.ParseMediaType(contentType); err != nil { return "", "", "", fmt.Errorf("invalid attachment content type: %w", err) } header.Set("Content-Type", contentType+`; name="`+escapeParameter(attachment.Filename)+`"`) header.Set("Content-Disposition", `attachment; filename="`+escapeParameter(filepath.Base(attachment.Filename))+`"`) header.Set("Content-Transfer-Encoding", "base64") part, err := writer.CreatePart(header) if err != nil { return "", "", "", err } encoded := make([]byte, base64.StdEncoding.EncodedLen(len(attachment.Data))) base64.StdEncoding.Encode(encoded, attachment.Data) for len(encoded) > 0 { lineLen := 76 if len(encoded) < lineLen { lineLen = len(encoded) } if _, err := part.Write(append(encoded[:lineLen], '\r', '\n')); err != nil { return "", "", "", err } encoded = encoded[lineLen:] } } if err := writer.Close(); err != nil { return "", "", "", err } return `multipart/mixed; boundary="` + boundary + `"`, "7bit", builder.String(), nil } func boundary() (string, error) { random := make([]byte, 12) if _, err := rand.Read(random); err != nil { return "", fmt.Errorf("generate MIME boundary: %w", err) } return "aegis-" + base64.RawURLEncoding.EncodeToString(random), nil } func escapeParameter(value string) string { return strings.NewReplacer("\\", "_", `"`, "'", "\r", "_", "\n", "_").Replace(value) } func normalizeCRLF(value string) string { value = strings.ReplaceAll(value, "\r\n", "\n") value = strings.ReplaceAll(value, "\r", "\n") return strings.ReplaceAll(value, "\n", "\r\n") }