// Package message handles email message parsing and reply composition. package message import ( "bytes" "encoding/base64" "errors" "fmt" "io" "log/slog" "strings" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" _ "github.com/emersion/go-message/charset" "github.com/emersion/go-message/mail" openai "github.com/sashabaranov/go-openai" ) // RFC5322 defines the date format specified in RFC 5322 §3.3. const RFC5322 = "Mon, 2 Jan 2006 15:04:05 -0700" // Supported image MIME types for vision models. var supportedImageTypes = map[string]bool{ "image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, } // maxPartSize is the maximum size in bytes for a single MIME part (32MB). const maxPartSize = 32 << 20 // Part represents a single piece of message content. type Part struct { // Content holds text content for text parts, empty for images. Content string // ContentType is the MIME type (e.g., "text/plain", "image/png"). ContentType string // Data holds raw bytes for binary content like images. Data []byte // Filename is set for attachment parts. Filename string // IsAttachment distinguishes attachments from inline content. IsAttachment bool } // IsImage returns true if the part is a supported image type. func (p *Part) IsImage() bool { return supportedImageTypes[p.ContentType] } // IsText returns true if the part contains text content. func (p *Part) IsText() bool { return strings.HasPrefix(p.ContentType, "text/") } // Message represents a parsed email with its metadata and content. type Message struct { // Attachments are parts with Content-Disposition: attachment. // Stored separately to allow appending after inline content. Attachments []Part Envelope *imap.Envelope // Parts contains inline content in order of appearance. Parts []Part // References are Message-IDs from the References header, // used for threading. References []string UID imap.UID log *slog.Logger } func (msg *Message) TextFrom() string { var str strings.Builder if msg.Envelope == nil || len(msg.Envelope.From) == 0 { return "" } str.WriteString("From: ") for i, a := range msg.Envelope.From { if a.Name == "" { fmt.Fprintf(&str, "%s", a.Addr()) } else { fmt.Fprintf( &str, "%s <%s>", a.Name, a.Addr(), ) } if i+1 < len(msg.Envelope.From) { str.WriteString(", ") } } return str.String() } // TextBody returns the concatenated text content from all text parts. // Used for reply composition and quoting. func (msg *Message) TextBody() string { var sb strings.Builder first := true for _, p := range msg.Parts { if p.IsText() { if !first { sb.WriteString("\n") } sb.WriteString(p.Content) first = false } } return sb.String() } // ToOpenAIMessages converts the message content to OpenAI chat message parts. // Inline parts appear first in order, followed by attachments. // Text parts become text content, supported images become image_url content. func (msg *Message) ToOpenAIMessages() []openai.ChatMessagePart { var parts = []openai.ChatMessagePart{} if msg != nil && msg.Envelope != nil { var str strings.Builder if v := msg.TextFrom(); v != "" { fmt.Fprintf(&str, "%s\n", v) } if !msg.Envelope.Date.IsZero() { fmt.Fprintf( &str, "Date: %s\n", msg.Envelope.Date.Format(time.RFC3339), ) } if msg.Envelope.Subject != "" { fmt.Fprintf(&str, "Subject: %v\n", msg.Envelope.Subject) } if v := str.String(); v != "" { parts = append( parts, openai.ChatMessagePart{ Type: openai.ChatMessagePartTypeText, Text: v, }, ) } } // Process inline parts first, preserving order. for _, p := range msg.Parts { if part, ok := convertPart(p); ok { parts = append(parts, part) } } // Append attachments at the end. for _, p := range msg.Attachments { if part, ok := convertPart(p); ok { parts = append(parts, part) } } return parts } // convertPart converts a Part to an OpenAI ChatMessagePart. // Returns false if the part type is not supported for LLM input. func convertPart(p Part) (openai.ChatMessagePart, bool) { switch { case p.IsText(): return openai.ChatMessagePart{ Type: openai.ChatMessagePartTypeText, Text: p.Content, }, true case p.IsImage(): dataURI := fmt.Sprintf( "data:%s;base64,%s", p.ContentType, base64.StdEncoding.EncodeToString(p.Data), ) return openai.ChatMessagePart{ Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{ URL: dataURI, Detail: openai.ImageURLDetailAuto, }, }, true default: return openai.ChatMessagePart{}, false } } // composeAttribution builds the attribution line for quoted replies. // Returns sender and timestamp in a standard format like: // "On Mon, 2 Jan 2006 15:04:05 -0700, raven wrote:" func (msg *Message) composeAttribution() string { if len(msg.Envelope.From) == 0 { return "> \n" } from := msg.Envelope.From[0] sender := from.Addr() if from.Name != "" { sender = fmt.Sprintf("%s <%s>", from.Name, from.Addr()) } if msg.Envelope.Date.IsZero() { return fmt.Sprintf("%s wrote:\n", sender) } return fmt.Sprintf( "On %s, %s wrote:\n", msg.Envelope.Date.Format(RFC5322), sender, ) } // composeBody assembles the reply body: user's response, attribution line, // and quoted original message. func (msg *Message) composeBody(res string) string { var body strings.Builder body.WriteString(strings.TrimRight(res, "\n\r \t")) body.WriteString("\n\n") body.WriteString(msg.composeAttribution()) body.WriteString(msg.QuotedBody()) return body.String() } // composeHeader builds RFC 5322-compliant headers for a reply. // Sets From, To, Subject, Date, Message-ID, In-Reply-To, and References. func (msg *Message) composeHeader( date time.Time, from *mail.Address, ) (*mail.Header, error) { h := &mail.Header{} h.SetDate(date) h.SetContentType("text/plain", map[string]string{"charset": "utf-8"}) h.SetAddressList("From", []*mail.Address{from}) h.SetAddressList("Reply-To", []*mail.Address{from}) to := msg.composeRecipients() if len(to) == 0 { return nil, errors.New("missing recipients") } h.SetAddressList("To", to) h.SetSubject(msg.composeSubject()) // Use sender's domain for Message-ID per RFC 5322 recommendation. parts := strings.SplitN(from.Address, "@", 2) if len(parts) == 2 { if err := h.GenerateMessageIDWithHostname(parts[1]); err != nil { return nil, fmt.Errorf("generate message id: %w", err) } } else { if err := h.GenerateMessageID(); err != nil { return nil, fmt.Errorf("generate message id: %w", err) } } inReplyTo, refs := msg.composeReferences() if inReplyTo != "" { h.SetMsgIDList("In-Reply-To", []string{inReplyTo}) } if len(refs) > 0 { h.SetMsgIDList("References", refs) } return h, nil } // composeReferences builds threading headers per RFC 5322 §3.6.4. // In-Reply-To contains the parent's Message-ID. // References contains the full thread ancestry. func (msg *Message) composeReferences() (inReplyTo string, refs []string) { if msg.Envelope.MessageID != "" { inReplyTo = msg.Envelope.MessageID refs = append([]string(nil), msg.References...) refs = append(refs, msg.Envelope.MessageID) } else { refs = append([]string(nil), msg.References...) } return } // composeRecipients determines the To address for a reply. // Uses Reply-To if present, otherwise From (per RFC 5322 §3.6.2). func (msg *Message) composeRecipients() []*mail.Address { src := msg.Envelope.ReplyTo if len(src) == 0 { src = msg.Envelope.From } to := make([]*mail.Address, 0, len(src)) for _, v := range src { to = append(to, &mail.Address{ Name: v.Name, Address: v.Addr(), }) } return to } // composeSubject prepends "Re: " if not already present (case-insensitive). func (msg *Message) composeSubject() string { s := msg.Envelope.Subject if !strings.HasPrefix(strings.ToLower(s), "re:") { s = "Re: " + s } return s } // ComposeReply creates a reply to this message. // The reply includes proper threading headers and the original message quoted. func (msg *Message) ComposeReply( date time.Time, from *mail.Address, res string, ) (*Reply, error) { if msg == nil || msg.Envelope == nil { return nil, errors.New("missing envelope") } if from == nil || from.Address == "" { return nil, errors.New("missing from address") } header, err := msg.composeHeader(date, from) if err != nil { return nil, fmt.Errorf("compose header: %v", err) } return &Reply{body: msg.composeBody(res), header: header}, nil } // QuotedBody returns the message text body with each line prefixed by "> ". func (msg *Message) QuotedBody() string { var quoted strings.Builder for line := range strings.SplitSeq(msg.TextBody(), "\n") { line = strings.TrimSuffix(line, "\r") quoted.WriteString("> ") quoted.WriteString(line) quoted.WriteString("\n") } return quoted.String() } // New creates a Message from an IMAP fetch buffer. // Handles multipart messages by collecting text parts and images inline, // with attachments stored separately for appending later. // Logs skipped parts and non-fatal errors. func New(mb *imapclient.FetchMessageBuffer, log *slog.Logger) (*Message, error) { if mb == nil { return nil, errors.New("nil message buffer") } if mb.UID == 0 { return nil, errors.New("message has no UID") } if mb.Envelope == nil { return nil, errors.New("message has no envelope") } if log == nil { log = slog.Default() } msg := &Message{ UID: mb.UID, Envelope: mb.Envelope, log: log, } // Try each body section until we successfully parse one. var parseErr error for _, section := range mb.BodySection { if len(section.Bytes) == 0 { continue } if err := parseBody( msg, bytes.NewReader(section.Bytes), log, ); err != nil { parseErr = err continue } if len(msg.Parts) > 0 { break } } // Failed to parse any content — return an error. if len(msg.Parts) == 0 && parseErr != nil { return msg, fmt.Errorf("parse body: %w", parseErr) } return msg, nil } // parseBody extracts content from a MIME message body. // mail.Reader automatically flattens nested multipart structures, returning // only leaf parts (text/plain, attachments, etc.). func parseBody(msg *Message, r io.Reader, log *slog.Logger) error { reader, err := mail.CreateReader(r) if err != nil { return fmt.Errorf("create reader: %w", err) } defer reader.Close() // Extract References header for threading. refs, err := reader.Header.MsgIDList("References") if err == nil && len(refs) > 0 { msg.References = refs } // Process all parts. for { part, err := reader.NextPart() if errors.Is(err, io.EOF) { break } if err != nil { return fmt.Errorf("next part: %w", err) } if err := processPart(msg, part, log); err != nil { log.Debug("skipped part", slog.Any("error", err), ) continue } } return nil } // processPart handles a single MIME part returned by mail.Reader. // Inline text and images are added to Parts; attachments go to Attachments. func processPart(msg *Message, part *mail.Part, log *slog.Logger) error { switch h := part.Header.(type) { case *mail.InlineHeader: ct, _, err := h.Header.ContentType() if err != nil { ct = "text/plain" } body, err := io.ReadAll(io.LimitReader(part.Body, maxPartSize)) if err != nil { return fmt.Errorf("read body: %w", err) } switch { case strings.HasPrefix(ct, "text/"): msg.Parts = append(msg.Parts, Part{ Content: string(body), ContentType: ct, }) case supportedImageTypes[ct]: msg.Parts = append(msg.Parts, Part{ ContentType: ct, Data: body, }) default: log.Debug("skipped unsupported inline content type", slog.String("content_type", ct), ) } case *mail.AttachmentHeader: filename, _ := h.Filename() ct, _, _ := h.Header.ContentType() body, err := io.ReadAll(io.LimitReader(part.Body, maxPartSize)) if err != nil { return fmt.Errorf("read attachment: %w", err) } // Only store attachments we can use (text or images). switch { case strings.HasPrefix(ct, "text/"): msg.Attachments = append(msg.Attachments, Part{ Content: string(body), ContentType: ct, Filename: filename, IsAttachment: true, }) case supportedImageTypes[ct]: msg.Attachments = append(msg.Attachments, Part{ ContentType: ct, Data: body, Filename: filename, IsAttachment: true, }) default: log.Debug("skipped unsupported attachment type", slog.String("content_type", ct), slog.String("filename", filename), ) } } return nil } // Reply holds a composed reply ready for sending. type Reply struct { body string header *mail.Header } // Bytes serializes the reply to RFC 5322 wire format. func (r *Reply) Bytes() ([]byte, error) { var buf bytes.Buffer mw, err := mail.CreateSingleInlineWriter(&buf, *r.header) if err != nil { return nil, fmt.Errorf("create writer: %w", err) } if _, err := mw.Write([]byte(r.body)); err != nil { return nil, fmt.Errorf("write body: %w", err) } if err := mw.Close(); err != nil { return nil, fmt.Errorf("close writer: %w", err) } return buf.Bytes(), nil } // Recipients returns the To addresses as formatted strings. func (r *Reply) Recipients() ([]string, error) { addrs, err := r.header.AddressList("To") if err != nil { return nil, fmt.Errorf("address list: %w", err) } to := make([]string, len(addrs)) for i, v := range addrs { to[i] = v.Address } return to, nil }