all repos — postern

@ 087f9dd30bc5a591621f1184af323569d6a0d240

Modern mail management

clone
git clone https:///postern.git

internal/policy/message.go (view raw)

  1package policy
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"mime"
  8	"net/mail"
  9	"strings"
 10	"sync"
 11
 12	"postern/internal/model"
 13	"postern/internal/persistence"
 14
 15	"github.com/emersion/go-message"
 16	_ "github.com/emersion/go-message/charset" // Importing package automatically sets message.CharsetReader
 17	"go.starlark.net/starlark"
 18)
 19
 20type Message struct {
 21	OpenStream func() (io.ReadCloser, error)
 22	Size       int64
 23
 24	bodyOnce sync.Once
 25	bodyErr  error
 26	body     []byte
 27
 28	headerOnce sync.Once
 29	headerErr  error
 30	header     mail.Header
 31
 32	attachmentsOnce sync.Once
 33	attachmentsErr  error
 34	textBody        string
 35	htmlBody        string
 36	attachments     []Attachment
 37}
 38
 39func (m *Message) String() string       { return "<MessageContext>" }
 40func (m *Message) Type() string         { return "MessageContext" }
 41func (m *Message) Freeze()              {}
 42func (m *Message) Truth() starlark.Bool { return true }
 43func (m *Message) Hash() (uint32, error) {
 44	return 0, errors.New("unhashable")
 45}
 46
 47func (m *Message) Attr(name string) (starlark.Value, error) {
 48	switch name {
 49	case "header_from":
 50		return m.HeaderFrom()
 51	case "header_to":
 52		return m.HeaderTo()
 53	case "subject":
 54		return m.Subject()
 55	case "size":
 56		return starlark.MakeInt64(m.Size), nil
 57	case "body":
 58		return m.Body()
 59	case "attachments":
 60		return m.Attachments()
 61	case "text_body":
 62		return m.TextBody()
 63	case "html_body":
 64		return m.HtmlBody()
 65	}
 66	return nil, nil
 67}
 68
 69func (m *Message) AttrNames() []string {
 70	return []string{"header_from", "header_to", "subject", "size", "body", "attachments", "text_body", "html_body"}
 71}
 72
 73func (m *Message) ensureHeaders() (_ mail.Header, err error) {
 74	m.headerOnce.Do(func() {
 75		r, err := m.OpenStream()
 76		if err != nil {
 77			m.headerErr = err
 78			return
 79		}
 80		defer model.Wrap(r.Close, &err, "ensureHeaders closing file stream")
 81		m.header, _, m.headerErr = persistence.ParseMailHeader(r)
 82	})
 83	return m.header, m.headerErr
 84}
 85
 86func (m *Message) HeaderFrom() (Address, error) {
 87	h, err := m.ensureHeaders()
 88	if err != nil {
 89		return Address{}, err
 90	}
 91	fromAddr, err := ParseAddress(h.Get("From"))
 92	if err != nil {
 93		return Address{}, model.ErrMissingFromHeader
 94	}
 95	return fromAddr, nil
 96}
 97
 98func (m *Message) HeaderTo() (Address, error) {
 99	h, err := m.ensureHeaders()
100	if err != nil {
101		return Address{}, err
102	}
103	toAddr, err := ParseAddress(h.Get("To"))
104	if err != nil {
105		return Address{}, errors.New("missing To header")
106	}
107	return toAddr, nil
108}
109
110func (m *Message) Subject() (starlark.Value, error) {
111	h, err := m.ensureHeaders()
112	if err != nil {
113		return nil, err
114	}
115	return starlark.String(h.Get("Subject")), nil
116}
117
118func (m *Message) Body() (_ starlark.Value, err error) {
119	m.bodyOnce.Do(func() {
120		r, err := m.OpenStream()
121		if err != nil {
122			m.bodyErr = err
123			return
124		}
125		defer model.Wrap(r.Close, &err, "body closing file stream")
126		m.body, m.bodyErr = io.ReadAll(r)
127	})
128	return starlark.Bytes(m.body), m.bodyErr
129}
130
131// Attachments returns a list of attachment objects extracted from the message.
132func (m *Message) Attachments() (starlark.Value, error) {
133	_, _, attachments, err := m.messageParts()
134	if err != nil {
135		return nil, err
136	}
137	elems := make([]starlark.Value, len(attachments))
138	for i, a := range attachments {
139		elems[i] = a
140	}
141	return starlark.NewList(elems), nil
142}
143
144// TextBody returns the plain text body of the message.
145func (m *Message) TextBody() (starlark.Value, error) {
146	textBody, _, _, err := m.messageParts()
147	if err != nil {
148		return nil, err
149	}
150	return starlark.String(textBody), nil
151}
152
153// HtmlBody returns the HTML body of the message.
154func (m *Message) HtmlBody() (starlark.Value, error) {
155	_, htmlBody, _, err := m.messageParts()
156	if err != nil {
157		return nil, err
158	}
159	return starlark.String(htmlBody), nil
160}
161
162// Attachment represents an extracted binary attachment or media file.
163type Attachment struct {
164	Filename    string
165	ContentType string
166	Data        []byte
167}
168
169func (a Attachment) String() string       { return "<Attachment>" }
170func (a Attachment) Type() string         { return "Attachment" }
171func (a Attachment) Freeze()              {}
172func (a Attachment) Truth() starlark.Bool { return true }
173func (a Attachment) Hash() (uint32, error) {
174	return 0, errors.New("unhashable")
175}
176
177func (a Attachment) Attr(name string) (starlark.Value, error) {
178	switch name {
179	case "filename":
180		return starlark.String(a.Filename), nil
181	case "content_type":
182		return starlark.String(a.ContentType), nil
183	case "data":
184		return starlark.Bytes(a.Data), nil
185	}
186	return nil, nil
187}
188
189func (a Attachment) AttrNames() []string {
190	return []string{"filename", "content_type", "data"}
191}
192
193// messageParts returns the plain text body, html body and attachments.
194func (m *Message) messageParts() (_ string, _ string, _ []Attachment, err error) {
195	m.attachmentsOnce.Do(func() {
196		r, err := m.OpenStream()
197		if err != nil {
198			m.attachmentsErr = err
199			return
200		}
201		defer model.Wrap(r.Close, &err, "messageParts closing file stream")
202
203		msg, err := message.Read(r)
204		if err != nil {
205			m.attachmentsErr = err
206			return
207		}
208
209		// Walk recursively traverses all nested multipart containers (mixed, alternative, related, etc.)
210		m.attachmentsErr = msg.Walk(func(path []int, entity *message.Entity, err error) error {
211			if err != nil {
212				return err
213			}
214
215			// Skip top-level or sub-container multipart headers; we only want leaf nodes with content
216			if entity.MultipartReader() != nil {
217				return nil
218			}
219
220			// Parse Content-Type header
221			contentType, params, err := entity.Header.ContentType()
222			if err != nil {
223				contentType = "text/plain"
224			}
225			mediaType, _, _ := mime.ParseMediaType(contentType)
226
227			// Parse Content-Disposition header
228			disposition, dispParams, _ := entity.Header.ContentDisposition()
229
230			// Extract filename (check disposition first, fallback to Content-Type parameter)
231			filename := dispParams["filename"]
232			if filename == "" {
233				filename = params["name"]
234			}
235
236			// Read entity body payload (go-message automatically handles base64, quoted-printable, and charsets)
237			bodyBytes, err := io.ReadAll(entity.Body)
238			if err != nil {
239				return fmt.Errorf("failed to read entity body: %w", err)
240			}
241
242			// 1. Explicit Attachments (Content-Disposition: attachment)
243			if disposition == "attachment" {
244				m.attachments = append(m.attachments, Attachment{
245					Filename:    filename,
246					ContentType: mediaType,
247					Data:        bodyBytes,
248				})
249				return nil
250			}
251
252			// 2. Text / HTML / Binary categorization
253			switch mediaType {
254			case "text/plain":
255				if m.textBody == "" {
256					m.textBody = string(bodyBytes)
257				} else {
258					m.textBody += "\n" + string(bodyBytes)
259				}
260
261			case "text/html":
262				if m.htmlBody == "" {
263					m.htmlBody = string(bodyBytes)
264				} else {
265					m.htmlBody += "\n" + string(bodyBytes)
266				}
267
268			default:
269				// Non-text payloads (PDFs, images, audio, application binaries, inline images, etc.)
270				if isBinaryType(mediaType) || disposition == "inline" {
271					m.attachments = append(m.attachments, Attachment{
272						Filename:    filename,
273						ContentType: mediaType,
274						Data:        bodyBytes,
275					})
276				}
277			}
278
279			return nil
280		})
281	})
282
283	return m.textBody, m.htmlBody, m.attachments, m.attachmentsErr
284}
285
286func isBinaryType(mediaType string) bool {
287	return strings.HasPrefix(mediaType, "image/") ||
288		strings.HasPrefix(mediaType, "application/") ||
289		strings.HasPrefix(mediaType, "audio/") ||
290		strings.HasPrefix(mediaType, "video/")
291}