Add answer worker
Subsystem for message processing: parses messages, generates LLM responses, and replies with SMTP. Introduces: - answer: message processing worker. - llm: OpenAI API compatible client with support for tool execution. - message: message parsing and response logic. - tool: converts YAML configuration into executable subprocesses. - smtp: simple config and client wrapper for sending email.
This commit is contained in:
529
internal/message/message.go
Normal file
529
internal/message/message.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// 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 <raven@example.com> 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
|
||||
}
|
||||
1352
internal/message/message_test.go
Normal file
1352
internal/message/message_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user