package policy import ( "errors" "fmt" "io" "mime" "net/mail" "strings" "sync" "postern/internal/model" "postern/internal/persistence" "github.com/emersion/go-message" _ "github.com/emersion/go-message/charset" // Importing package automatically sets message.CharsetReader "go.starlark.net/starlark" ) type Message struct { OpenStream func() (io.ReadCloser, error) Size int64 bodyOnce sync.Once bodyErr error body []byte headerOnce sync.Once headerErr error header mail.Header attachmentsOnce sync.Once attachmentsErr error textBody string htmlBody string attachments []Attachment } func (m *Message) String() string { return "" } func (m *Message) Type() string { return "MessageContext" } func (m *Message) Freeze() {} func (m *Message) Truth() starlark.Bool { return true } func (m *Message) Hash() (uint32, error) { return 0, errors.New("unhashable") } func (m *Message) Attr(name string) (starlark.Value, error) { switch name { case "header_from": return m.HeaderFrom() case "header_to": return m.HeaderTo() case "subject": return m.Subject() case "size": return starlark.MakeInt64(m.Size), nil case "body": return m.Body() case "attachments": return m.Attachments() case "text_body": return m.TextBody() case "html_body": return m.HtmlBody() } return nil, nil } func (m *Message) AttrNames() []string { return []string{"header_from", "header_to", "subject", "size", "body", "attachments", "text_body", "html_body"} } func (m *Message) ensureHeaders() (_ mail.Header, err error) { m.headerOnce.Do(func() { r, err := m.OpenStream() if err != nil { m.headerErr = err return } defer model.Wrap(r.Close, &err, "ensureHeaders closing file stream") m.header, _, m.headerErr = persistence.ParseMailHeader(r) }) return m.header, m.headerErr } func (m *Message) HeaderFrom() (Address, error) { h, err := m.ensureHeaders() if err != nil { return Address{}, err } fromAddr, err := ParseAddress(h.Get("From")) if err != nil { return Address{}, model.ErrMissingFromHeader } return fromAddr, nil } func (m *Message) HeaderTo() (Address, error) { h, err := m.ensureHeaders() if err != nil { return Address{}, err } toAddr, err := ParseAddress(h.Get("To")) if err != nil { return Address{}, errors.New("missing To header") } return toAddr, nil } func (m *Message) Subject() (starlark.Value, error) { h, err := m.ensureHeaders() if err != nil { return nil, err } return starlark.String(h.Get("Subject")), nil } func (m *Message) Body() (_ starlark.Value, err error) { m.bodyOnce.Do(func() { r, err := m.OpenStream() if err != nil { m.bodyErr = err return } defer model.Wrap(r.Close, &err, "body closing file stream") m.body, m.bodyErr = io.ReadAll(r) }) return starlark.Bytes(m.body), m.bodyErr } // Attachments returns a list of attachment objects extracted from the message. func (m *Message) Attachments() (starlark.Value, error) { _, _, attachments, err := m.messageParts() if err != nil { return nil, err } elems := make([]starlark.Value, len(attachments)) for i, a := range attachments { elems[i] = a } return starlark.NewList(elems), nil } // TextBody returns the plain text body of the message. func (m *Message) TextBody() (starlark.Value, error) { textBody, _, _, err := m.messageParts() if err != nil { return nil, err } return starlark.String(textBody), nil } // HtmlBody returns the HTML body of the message. func (m *Message) HtmlBody() (starlark.Value, error) { _, htmlBody, _, err := m.messageParts() if err != nil { return nil, err } return starlark.String(htmlBody), nil } // Attachment represents an extracted binary attachment or media file. type Attachment struct { Filename string ContentType string Data []byte } func (a Attachment) String() string { return "" } func (a Attachment) Type() string { return "Attachment" } func (a Attachment) Freeze() {} func (a Attachment) Truth() starlark.Bool { return true } func (a Attachment) Hash() (uint32, error) { return 0, errors.New("unhashable") } func (a Attachment) Attr(name string) (starlark.Value, error) { switch name { case "filename": return starlark.String(a.Filename), nil case "content_type": return starlark.String(a.ContentType), nil case "data": return starlark.Bytes(a.Data), nil } return nil, nil } func (a Attachment) AttrNames() []string { return []string{"filename", "content_type", "data"} } // messageParts returns the plain text body, html body and attachments. func (m *Message) messageParts() (_ string, _ string, _ []Attachment, err error) { m.attachmentsOnce.Do(func() { r, err := m.OpenStream() if err != nil { m.attachmentsErr = err return } defer model.Wrap(r.Close, &err, "messageParts closing file stream") msg, err := message.Read(r) if err != nil { m.attachmentsErr = err return } // Walk recursively traverses all nested multipart containers (mixed, alternative, related, etc.) m.attachmentsErr = msg.Walk(func(path []int, entity *message.Entity, err error) error { if err != nil { return err } // Skip top-level or sub-container multipart headers; we only want leaf nodes with content if entity.MultipartReader() != nil { return nil } // Parse Content-Type header contentType, params, err := entity.Header.ContentType() if err != nil { contentType = "text/plain" } mediaType, _, _ := mime.ParseMediaType(contentType) // Parse Content-Disposition header disposition, dispParams, _ := entity.Header.ContentDisposition() // Extract filename (check disposition first, fallback to Content-Type parameter) filename := dispParams["filename"] if filename == "" { filename = params["name"] } // Read entity body payload (go-message automatically handles base64, quoted-printable, and charsets) bodyBytes, err := io.ReadAll(entity.Body) if err != nil { return fmt.Errorf("failed to read entity body: %w", err) } // 1. Explicit Attachments (Content-Disposition: attachment) if disposition == "attachment" { m.attachments = append(m.attachments, Attachment{ Filename: filename, ContentType: mediaType, Data: bodyBytes, }) return nil } // 2. Text / HTML / Binary categorization switch mediaType { case "text/plain": if m.textBody == "" { m.textBody = string(bodyBytes) } else { m.textBody += "\n" + string(bodyBytes) } case "text/html": if m.htmlBody == "" { m.htmlBody = string(bodyBytes) } else { m.htmlBody += "\n" + string(bodyBytes) } default: // Non-text payloads (PDFs, images, audio, application binaries, inline images, etc.) if isBinaryType(mediaType) || disposition == "inline" { m.attachments = append(m.attachments, Attachment{ Filename: filename, ContentType: mediaType, Data: bodyBytes, }) } } return nil }) }) return m.textBody, m.htmlBody, m.attachments, m.attachmentsErr } func isBinaryType(mediaType string) bool { return strings.HasPrefix(mediaType, "image/") || strings.HasPrefix(mediaType, "application/") || strings.HasPrefix(mediaType, "audio/") || strings.HasPrefix(mediaType, "video/") }