mirror of
https://github.com/hanzoskill/sogcli.git
synced 2026-10-30 21:03:29 +00:00
665 lines
15 KiB
Go
665 lines
15 KiB
Go
// Package imap provides IMAP client functionality.
|
|
package imap
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
)
|
|
|
|
// Client wraps an IMAP connection.
|
|
type Client struct {
|
|
client *imapclient.Client
|
|
email string
|
|
}
|
|
|
|
// Config holds IMAP connection configuration.
|
|
type Config struct {
|
|
Host string
|
|
Port int
|
|
TLS bool
|
|
Insecure bool // Skip TLS cert verification
|
|
NoTLS bool // Disable TLS entirely
|
|
Email string
|
|
Password string
|
|
}
|
|
|
|
// Connect establishes an IMAP connection.
|
|
func Connect(cfg Config) (*Client, error) {
|
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
|
|
|
var client *imapclient.Client
|
|
var err error
|
|
|
|
if cfg.NoTLS {
|
|
// Plain text connection
|
|
client, err = imapclient.DialInsecure(addr, nil)
|
|
} else if cfg.TLS {
|
|
// TLS connection
|
|
tlsConfig := &tls.Config{
|
|
ServerName: cfg.Host,
|
|
InsecureSkipVerify: cfg.Insecure,
|
|
}
|
|
opts := &imapclient.Options{
|
|
TLSConfig: tlsConfig,
|
|
}
|
|
client, err = imapclient.DialTLS(addr, opts)
|
|
} else {
|
|
client, err = imapclient.DialInsecure(addr, nil)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
|
|
// Login
|
|
if err := client.Login(cfg.Email, cfg.Password).Wait(); err != nil {
|
|
client.Close()
|
|
return nil, fmt.Errorf("failed to login: %w", err)
|
|
}
|
|
|
|
return &Client{client: client, email: cfg.Email}, nil
|
|
}
|
|
|
|
// Close closes the IMAP connection.
|
|
func (c *Client) Close() error {
|
|
if c.client != nil {
|
|
return c.client.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListFolders returns all mailbox names.
|
|
func (c *Client) ListFolders() ([]string, error) {
|
|
listCmd := c.client.List("", "*", nil)
|
|
mailboxes, err := listCmd.Collect()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list folders: %w", err)
|
|
}
|
|
|
|
names := make([]string, len(mailboxes))
|
|
for i, mb := range mailboxes {
|
|
names[i] = mb.Mailbox
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// Message represents an email message.
|
|
type Message struct {
|
|
UID uint32
|
|
Subject string
|
|
From string
|
|
To string
|
|
Date string
|
|
Seen bool
|
|
Body string
|
|
}
|
|
|
|
// ListMessages returns messages from a folder.
|
|
func (c *Client) ListMessages(folder string, max int, unseenOnly bool) ([]Message, error) {
|
|
// Select mailbox
|
|
selectData, err := c.client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
// If no messages, return early
|
|
if selectData.NumMessages == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Build sequence set for last N messages
|
|
start := uint32(1)
|
|
if selectData.NumMessages > uint32(max) {
|
|
start = selectData.NumMessages - uint32(max) + 1
|
|
}
|
|
seqSet := imap.SeqSet{}
|
|
seqSet.AddRange(start, selectData.NumMessages)
|
|
|
|
// Fetch messages
|
|
fetchOptions := &imap.FetchOptions{
|
|
Flags: true,
|
|
Envelope: true,
|
|
UID: true,
|
|
}
|
|
|
|
fetchCmd := c.client.Fetch(seqSet, fetchOptions)
|
|
messages := make([]Message, 0, max)
|
|
|
|
for {
|
|
msgData := fetchCmd.Next()
|
|
if msgData == nil {
|
|
break
|
|
}
|
|
|
|
buf, err := msgData.Collect()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
m := Message{
|
|
UID: uint32(buf.UID),
|
|
}
|
|
|
|
if buf.Envelope != nil {
|
|
m.Subject = buf.Envelope.Subject
|
|
m.Date = buf.Envelope.Date.Format("Jan 02")
|
|
if len(buf.Envelope.From) > 0 {
|
|
from := buf.Envelope.From[0]
|
|
if from.Name != "" {
|
|
m.From = from.Name
|
|
} else {
|
|
m.From = from.Addr()
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, f := range buf.Flags {
|
|
if f == imap.FlagSeen {
|
|
m.Seen = true
|
|
break
|
|
}
|
|
}
|
|
|
|
messages = append(messages, m)
|
|
}
|
|
|
|
if err := fetchCmd.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// GetMessage fetches a single message by UID.
|
|
func (c *Client) GetMessage(folder string, uid uint32, headersOnly bool) (*Message, error) {
|
|
// Select mailbox
|
|
_, err := c.client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
uidSet := imap.UIDSet{}
|
|
uidSet.AddNum(imap.UID(uid))
|
|
|
|
fetchOptions := &imap.FetchOptions{
|
|
Flags: true,
|
|
Envelope: true,
|
|
UID: true,
|
|
}
|
|
|
|
if !headersOnly {
|
|
fetchOptions.BodySection = []*imap.FetchItemBodySection{{}}
|
|
}
|
|
|
|
fetchCmd := c.client.Fetch(uidSet, fetchOptions)
|
|
|
|
msgData := fetchCmd.Next()
|
|
if msgData == nil {
|
|
return nil, fmt.Errorf("message not found: %d", uid)
|
|
}
|
|
|
|
buf, err := msgData.Collect()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to collect message: %w", err)
|
|
}
|
|
|
|
m := &Message{
|
|
UID: uint32(buf.UID),
|
|
}
|
|
|
|
if buf.Envelope != nil {
|
|
m.Subject = buf.Envelope.Subject
|
|
m.Date = buf.Envelope.Date.String()
|
|
if len(buf.Envelope.From) > 0 {
|
|
m.From = buf.Envelope.From[0].Addr()
|
|
}
|
|
if len(buf.Envelope.To) > 0 {
|
|
m.To = buf.Envelope.To[0].Addr()
|
|
}
|
|
}
|
|
|
|
// Extract body
|
|
if len(buf.BodySection) > 0 {
|
|
m.Body = string(buf.BodySection[0].Bytes)
|
|
}
|
|
|
|
if err := fetchCmd.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// SearchMessages searches for messages matching the query.
|
|
// Query format: IMAP-style search terms (FROM, TO, SUBJECT, SINCE, BEFORE, TEXT, etc.)
|
|
// Examples:
|
|
// - "ALL" - all messages (fallback to list)
|
|
// - "FROM viz" - messages from viz
|
|
// - "SUBJECT meeting" - messages with "meeting" in subject
|
|
// - "SINCE 1-Jan-2026" - messages since date
|
|
// - "FROM viz SUBJECT test" - combined search
|
|
func (c *Client) SearchMessages(folder, query string, max int) ([]Message, error) {
|
|
// Parse query into search criteria
|
|
criteria, err := parseSearchQuery(query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse query: %w", err)
|
|
}
|
|
|
|
// If criteria is nil (ALL), fall back to ListMessages
|
|
if criteria == nil {
|
|
return c.ListMessages(folder, max, false)
|
|
}
|
|
|
|
// Select mailbox
|
|
_, err = c.client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
// Search for messages
|
|
searchCmd := c.client.Search(criteria, nil)
|
|
searchData, err := searchCmd.Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to search: %w", err)
|
|
}
|
|
|
|
// Get UIDs
|
|
uids := searchData.AllUIDs()
|
|
if len(uids) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Limit results (take most recent)
|
|
if len(uids) > max {
|
|
uids = uids[len(uids)-max:]
|
|
}
|
|
|
|
// Fetch messages
|
|
fetchOptions := &imap.FetchOptions{
|
|
Flags: true,
|
|
Envelope: true,
|
|
UID: true,
|
|
}
|
|
|
|
uidSet := imap.UIDSet{}
|
|
for _, uid := range uids {
|
|
uidSet.AddNum(uid)
|
|
}
|
|
|
|
fetchCmd := c.client.Fetch(uidSet, fetchOptions)
|
|
messages := make([]Message, 0, len(uids))
|
|
|
|
for {
|
|
msgData := fetchCmd.Next()
|
|
if msgData == nil {
|
|
break
|
|
}
|
|
|
|
buf, err := msgData.Collect()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
m := Message{
|
|
UID: uint32(buf.UID),
|
|
}
|
|
|
|
if buf.Envelope != nil {
|
|
m.Subject = buf.Envelope.Subject
|
|
m.Date = buf.Envelope.Date.Format("Jan 02")
|
|
if len(buf.Envelope.From) > 0 {
|
|
from := buf.Envelope.From[0]
|
|
if from.Name != "" {
|
|
m.From = from.Name
|
|
} else {
|
|
m.From = from.Addr()
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, f := range buf.Flags {
|
|
if f == imap.FlagSeen {
|
|
m.Seen = true
|
|
break
|
|
}
|
|
}
|
|
|
|
messages = append(messages, m)
|
|
}
|
|
|
|
if err := fetchCmd.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// parseSearchQuery parses a simple search query into IMAP search criteria.
|
|
// Returns nil criteria for "ALL" to indicate list-all fallback.
|
|
func parseSearchQuery(query string) (*imap.SearchCriteria, error) {
|
|
// Special case: ALL returns nil to trigger fallback
|
|
if strings.ToUpper(strings.TrimSpace(query)) == "ALL" {
|
|
return nil, nil
|
|
}
|
|
|
|
criteria := &imap.SearchCriteria{}
|
|
|
|
// Simple parser: look for known keywords
|
|
tokens := strings.Fields(query)
|
|
|
|
for i := 0; i < len(tokens); i++ {
|
|
keyword := strings.ToUpper(tokens[i])
|
|
|
|
switch keyword {
|
|
case "FROM":
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
|
|
Key: "From",
|
|
Value: tokens[i],
|
|
})
|
|
}
|
|
case "TO":
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
|
|
Key: "To",
|
|
Value: tokens[i],
|
|
})
|
|
}
|
|
case "SUBJECT":
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
|
|
Key: "Subject",
|
|
Value: tokens[i],
|
|
})
|
|
}
|
|
case "TEXT", "BODY":
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
criteria.Text = append(criteria.Text, tokens[i])
|
|
}
|
|
case "UNSEEN", "UNREAD":
|
|
criteria.NotFlag = append(criteria.NotFlag, imap.FlagSeen)
|
|
case "SEEN", "READ":
|
|
criteria.Flag = append(criteria.Flag, imap.FlagSeen)
|
|
case "FLAGGED", "STARRED":
|
|
criteria.Flag = append(criteria.Flag, imap.FlagFlagged)
|
|
case "SINCE":
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
t, err := parseDate(tokens[i])
|
|
if err == nil {
|
|
criteria.Since = t
|
|
}
|
|
}
|
|
case "BEFORE":
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
t, err := parseDate(tokens[i])
|
|
if err == nil {
|
|
criteria.Before = t
|
|
}
|
|
}
|
|
default:
|
|
// Treat as text search
|
|
criteria.Text = append(criteria.Text, tokens[i])
|
|
}
|
|
}
|
|
|
|
return criteria, nil
|
|
}
|
|
|
|
// parseDate parses common date formats.
|
|
func parseDate(s string) (time.Time, error) {
|
|
formats := []string{
|
|
"2-Jan-2006",
|
|
"02-Jan-2006",
|
|
"2006-01-02",
|
|
"01/02/2006",
|
|
"1/2/2006",
|
|
}
|
|
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, s); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
return time.Time{}, fmt.Errorf("unable to parse date: %s", s)
|
|
}
|
|
|
|
|
|
// MoveMessage moves a message to a different folder.
|
|
func (c *Client) MoveMessage(srcFolder string, uid uint32, dstFolder string) error {
|
|
// Select source mailbox
|
|
_, err := c.client.Select(srcFolder, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
uidSet := imap.UIDSet{}
|
|
uidSet.AddNum(imap.UID(uid))
|
|
|
|
// Try MOVE command (handles fallback internally)
|
|
moveCmd := c.client.Move(uidSet, dstFolder)
|
|
_, err = moveCmd.Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to move: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CopyMessage copies a message to a different folder.
|
|
func (c *Client) CopyMessage(srcFolder string, uid uint32, dstFolder string) error {
|
|
// Select source mailbox
|
|
_, err := c.client.Select(srcFolder, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
uidSet := imap.UIDSet{}
|
|
uidSet.AddNum(imap.UID(uid))
|
|
|
|
copyCmd := c.client.Copy(uidSet, dstFolder)
|
|
_, err = copyCmd.Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to copy: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetFlag adds or removes a flag on a message.
|
|
func (c *Client) SetFlag(folder string, uid uint32, flag string, add bool) error {
|
|
// Select mailbox
|
|
_, err := c.client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
uidSet := imap.UIDSet{}
|
|
uidSet.AddNum(imap.UID(uid))
|
|
|
|
// Map flag name to IMAP flag
|
|
var imapFlag imap.Flag
|
|
switch strings.ToLower(flag) {
|
|
case "seen", "read":
|
|
imapFlag = imap.FlagSeen
|
|
case "flagged", "starred":
|
|
imapFlag = imap.FlagFlagged
|
|
case "answered", "replied":
|
|
imapFlag = imap.FlagAnswered
|
|
case "deleted":
|
|
imapFlag = imap.FlagDeleted
|
|
case "draft":
|
|
imapFlag = imap.FlagDraft
|
|
default:
|
|
return fmt.Errorf("unknown flag: %s", flag)
|
|
}
|
|
|
|
op := imap.StoreFlagsAdd
|
|
if !add {
|
|
op = imap.StoreFlagsDel
|
|
}
|
|
|
|
storeFlags := &imap.StoreFlags{
|
|
Op: op,
|
|
Silent: true,
|
|
Flags: []imap.Flag{imapFlag},
|
|
}
|
|
|
|
storeCmd := c.client.Store(uidSet, storeFlags, nil)
|
|
if err := storeCmd.Close(); err != nil {
|
|
return fmt.Errorf("failed to set flag: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteMessage marks a message as deleted and expunges.
|
|
func (c *Client) DeleteMessage(folder string, uid uint32) error {
|
|
if err := c.SetFlag(folder, uid, "deleted", true); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Expunge
|
|
expungeCmd := c.client.Expunge()
|
|
if err := expungeCmd.Close(); err != nil {
|
|
return fmt.Errorf("failed to expunge: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateFolder creates a new mailbox.
|
|
func (c *Client) CreateFolder(name string) error {
|
|
createCmd := c.client.Create(name, nil)
|
|
if err := createCmd.Wait(); err != nil {
|
|
return fmt.Errorf("failed to create folder: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteFolder deletes a mailbox.
|
|
func (c *Client) DeleteFolder(name string) error {
|
|
deleteCmd := c.client.Delete(name)
|
|
if err := deleteCmd.Wait(); err != nil {
|
|
return fmt.Errorf("failed to delete folder: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RenameFolder renames a mailbox.
|
|
func (c *Client) RenameFolder(oldName, newName string) error {
|
|
renameCmd := c.client.Rename(oldName, newName)
|
|
if err := renameCmd.Wait(); err != nil {
|
|
return fmt.Errorf("failed to rename folder: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
|
|
// SaveDraft saves a message to the Drafts folder.
|
|
func (c *Client) SaveDraft(msg *Message) (uint32, error) {
|
|
// Build RFC822 message
|
|
var content strings.Builder
|
|
content.WriteString(fmt.Sprintf("From: %s\r\n", msg.From))
|
|
if msg.To != "" {
|
|
content.WriteString(fmt.Sprintf("To: %s\r\n", msg.To))
|
|
}
|
|
content.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
|
content.WriteString("MIME-Version: 1.0\r\n")
|
|
content.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
content.WriteString("\r\n")
|
|
content.WriteString(msg.Body)
|
|
|
|
msgBytes := []byte(content.String())
|
|
|
|
// Append to Drafts with Draft flag
|
|
appendCmd := c.client.Append("Drafts", int64(len(msgBytes)), &imap.AppendOptions{
|
|
Flags: []imap.Flag{imap.FlagDraft},
|
|
})
|
|
|
|
if _, err := appendCmd.Write(msgBytes); err != nil {
|
|
return 0, fmt.Errorf("failed to write draft: %w", err)
|
|
}
|
|
|
|
if err := appendCmd.Close(); err != nil {
|
|
return 0, fmt.Errorf("failed to close draft: %w", err)
|
|
}
|
|
|
|
data, err := appendCmd.Wait()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to save draft: %w", err)
|
|
}
|
|
|
|
if data.UID != 0 {
|
|
return uint32(data.UID), nil
|
|
}
|
|
|
|
return 0, nil
|
|
}
|
|
|
|
// ListDrafts returns messages from the Drafts folder.
|
|
func (c *Client) ListDrafts(max int) ([]Message, error) {
|
|
return c.ListMessages("Drafts", max, false)
|
|
}
|
|
|
|
// DeleteDraft deletes a draft by UID.
|
|
func (c *Client) DeleteDraft(uid uint32) error {
|
|
return c.DeleteMessage("Drafts", uid)
|
|
}
|
|
|
|
// Idle starts IMAP IDLE and calls the callback when new mail arrives.
|
|
|
|
// Idle starts IMAP IDLE and calls the callback when new mail arrives.
|
|
// This is a simplified implementation that uses polling with IDLE keepalive.
|
|
func (c *Client) Idle(folder string, callback func(msgCount uint32)) error {
|
|
// Select mailbox
|
|
selectData, err := c.client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to select folder: %w", err)
|
|
}
|
|
|
|
lastCount := selectData.NumMessages
|
|
|
|
for {
|
|
// Start IDLE - this blocks until server sends data or timeout
|
|
idleCmd, err := c.client.Idle()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start idle: %w", err)
|
|
}
|
|
|
|
// Wait for IDLE to complete (server sends untagged response or timeout)
|
|
err = idleCmd.Wait()
|
|
if err != nil {
|
|
idleCmd.Close()
|
|
return fmt.Errorf("idle error: %w", err)
|
|
}
|
|
|
|
idleCmd.Close()
|
|
|
|
// Check for new messages by doing a NOOP
|
|
status, err := c.client.Status(folder, &imap.StatusOptions{
|
|
NumMessages: true,
|
|
}).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("status error: %w", err)
|
|
}
|
|
|
|
if status.NumMessages != nil && *status.NumMessages > lastCount {
|
|
if callback != nil {
|
|
callback(*status.NumMessages)
|
|
}
|
|
lastCount = *status.NumMessages
|
|
}
|
|
}
|
|
}
|