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}