package mail import ( "bytes" "fmt" "io" "log" "mime" "strings" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-message" "mail2couch/config" ) // ImapClient wraps the IMAP client type ImapClient struct { *imapclient.Client } // Message represents an email message retrieved from IMAP type Message struct { UID uint32 From []string To []string Subject string Date time.Time Body string Headers map[string][]string Attachments []Attachment } // Attachment represents an email attachment type Attachment struct { Filename string ContentType string Content []byte } // NewImapClient creates a new IMAP client from the configuration func NewImapClient(source *config.MailSource) (*ImapClient, error) { addr := fmt.Sprintf("%s:%d", source.Host, source.Port) var client *imapclient.Client var err error // Try TLS first for standard IMAPS ports (993, 465) if source.Port == 993 || source.Port == 465 { client, err = imapclient.DialTLS(addr, nil) } else { // Use insecure connection for other ports (143, 3143, etc.) client, err = imapclient.DialInsecure(addr, nil) } if err != nil { return nil, fmt.Errorf("failed to dial IMAP server: %w", err) } if err := client.Login(source.User, source.Password).Wait(); err != nil { return nil, fmt.Errorf("failed to login: %w", err) } return &ImapClient{client}, nil } // ListMailboxes lists all available mailboxes func (c *ImapClient) ListMailboxes() ([]string, error) { var mailboxes []string cmd := c.List("", "*", nil) infos, err := cmd.Collect() if err != nil { return nil, err } for _, info := range infos { mailboxes = append(mailboxes, info.Mailbox) } return mailboxes, nil } // GetMessages retrieves messages from a specific mailbox (simplified version) // Returns messages and a map of all current UIDs in the mailbox // maxMessages: 0 means no limit, > 0 limits the number of messages to fetch func (c *ImapClient) GetMessages(mailbox string, since *time.Time, maxMessages int) ([]*Message, map[uint32]bool, error) { // Select the mailbox mbox, err := c.Select(mailbox, nil).Wait() if err != nil { return nil, nil, fmt.Errorf("failed to select mailbox %s: %w", mailbox, err) } if mbox.NumMessages == 0 { return []*Message{}, make(map[uint32]bool), nil } // For now, use a simpler approach to get all sequence numbers var messages []*Message currentUIDs := make(map[uint32]bool) // Determine how many messages to fetch numToFetch := mbox.NumMessages if maxMessages > 0 && int(numToFetch) > maxMessages { numToFetch = uint32(maxMessages) } if numToFetch == 0 { return []*Message{}, currentUIDs, nil } // Create sequence set for fetching (1:numToFetch) seqSet := imap.SeqSet{} seqSet.AddRange(1, numToFetch) // Track all sequence numbers (for sync we'll need to get UIDs later) for i := uint32(1); i <= mbox.NumMessages; i++ { currentUIDs[i] = true // Using sequence numbers for now } // Fetch message data - get envelope and full message body options := &imap.FetchOptions{ Envelope: true, UID: true, BodySection: []*imap.FetchItemBodySection{ {}, // Empty section gets the entire message }, } fetchCmd := c.Fetch(seqSet, options) for { msg := fetchCmd.Next() if msg == nil { break } parsedMsg, err := c.parseMessage(msg) if err != nil { log.Printf("Failed to parse message: %v", err) continue } messages = append(messages, parsedMsg) } if err := fetchCmd.Close(); err != nil { return nil, nil, fmt.Errorf("failed to fetch messages: %w", err) } return messages, currentUIDs, nil } // parseMessage parses an IMAP fetch response into our Message struct func (c *ImapClient) parseMessage(fetchMsg *imapclient.FetchMessageData) (*Message, error) { msg := &Message{ UID: fetchMsg.SeqNum, // Using sequence number for now Headers: make(map[string][]string), Attachments: []Attachment{}, } // Collect all fetch data first buffer, err := fetchMsg.Collect() if err != nil { return nil, fmt.Errorf("failed to collect fetch data: %w", err) } // Parse envelope for basic headers if buffer.Envelope != nil { env := buffer.Envelope msg.Subject = env.Subject msg.Date = env.Date // Parse From addresses for _, addr := range env.From { if addr.Mailbox != "" { fullAddr := addr.Mailbox if addr.Host != "" { fullAddr = addr.Mailbox + "@" + addr.Host } msg.From = append(msg.From, fullAddr) } } // Parse To addresses for _, addr := range env.To { if addr.Mailbox != "" { fullAddr := addr.Mailbox if addr.Host != "" { fullAddr = addr.Mailbox + "@" + addr.Host } msg.To = append(msg.To, fullAddr) } } } // Get UID if available if buffer.UID != 0 { msg.UID = uint32(buffer.UID) } // Parse full message content if len(buffer.BodySection) > 0 { bodyBuffer := buffer.BodySection[0] reader := bytes.NewReader(bodyBuffer.Bytes) // Parse the message using go-message entity, err := message.Read(reader) if err != nil { return nil, fmt.Errorf("failed to parse message: %w", err) } // Extract headers header := entity.Header for field := header.Fields(); field.Next(); { key := field.Key() value, _ := field.Text() msg.Headers[key] = append(msg.Headers[key], value) } // Parse message body and attachments if err := c.parseMessageBody(entity, msg); err != nil { return nil, fmt.Errorf("failed to parse message body: %w", err) } } return msg, nil } // parseMessageBody extracts the body and attachments from a message entity func (c *ImapClient) parseMessageBody(entity *message.Entity, msg *Message) error { mediaType, _, err := entity.Header.ContentType() if err != nil { // Default to text/plain if no content type mediaType = "text/plain" } if strings.HasPrefix(mediaType, "multipart/") { // Handle multipart message mr := entity.MultipartReader() if mr == nil { return fmt.Errorf("failed to create multipart reader") } for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { return fmt.Errorf("failed to read multipart: %w", err) } if err := c.parseMessagePart(part, msg); err != nil { log.Printf("Failed to parse message part: %v", err) // Continue processing other parts } } } else { // Handle single part message if err := c.parseMessagePart(entity, msg); err != nil { return err } } return nil } // parseMessagePart processes a single message part (body or attachment) func (c *ImapClient) parseMessagePart(entity *message.Entity, msg *Message) error { mediaType, params, err := entity.Header.ContentType() if err != nil { mediaType = "text/plain" } // Get content disposition disposition, dispositionParams, _ := entity.Header.ContentDisposition() // Determine if this is an attachment isAttachment := disposition == "attachment" || (disposition == "inline" && dispositionParams["filename"] != "") || params["name"] != "" if isAttachment { // Handle attachment filename := dispositionParams["filename"] if filename == "" { filename = params["name"] } if filename == "" { filename = "unnamed_attachment" } // Decode filename if needed decoder := &mime.WordDecoder{} filename, _ = decoder.DecodeHeader(filename) // Read attachment content content, err := io.ReadAll(entity.Body) if err != nil { return fmt.Errorf("failed to read attachment content: %w", err) } attachment := Attachment{ Filename: filename, ContentType: mediaType, Content: content, } msg.Attachments = append(msg.Attachments, attachment) } else if strings.HasPrefix(mediaType, "text/") && msg.Body == "" { // Handle text body (only take the first text part as body) bodyBytes, err := io.ReadAll(entity.Body) if err != nil { return fmt.Errorf("failed to read message body: %w", err) } msg.Body = string(bodyBytes) } return nil } // ShouldProcessMailbox checks if a mailbox should be processed based on filters func (c *ImapClient) ShouldProcessMailbox(mailbox string, filter *config.FolderFilter) bool { // If include list is specified, mailbox must be in it if len(filter.Include) > 0 { found := false for _, included := range filter.Include { if mailbox == included { found = true break } } if !found { return false } } // If exclude list is specified, mailbox must not be in it for _, excluded := range filter.Exclude { if mailbox == excluded { return false } } return true } // Logout logs the client out func (c *ImapClient) Logout() { if err := c.Client.Logout(); err != nil { log.Printf("Failed to logout: %v", err) } }