package main import ( "crypto/tls" "encoding/base64" "flag" "fmt" "io" "net/smtp" "os" "path/filepath" "strings" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" ) // Build-time configuration (set via ldflags at compile time) var ( BuildIMAPServer string BuildIMAPPort string BuildIMAPUser string BuildIMAPPassword string BuildSMTPServer string BuildSMTPPort string BuildSMTPUser string BuildSMTPPassword string BuildUseSSL string BuildIgnoreCerts string ) // Config holds email connection configuration type Config struct { IMAPServer string IMAPPort string IMAPUser string IMAPPassword string SMTPServer string SMTPPort string SMTPUser string SMTPPassword string UseSSL bool IgnoreCerts bool } func main() { // Parse flags (with build-time defaults) imapServer := flag.String("imap-server", BuildIMAPServer, "IMAP server address") imapPort := flag.String("imap-port", BuildIMAPPort, "IMAP port (default: 993 for SSL, 143 for STARTTLS)") imapUser := flag.String("imap-user", BuildIMAPUser, "IMAP username") imapPass := flag.String("imap-pass", BuildIMAPPassword, "IMAP password") smtpServer := flag.String("smtp-server", BuildSMTPServer, "SMTP server address") smtpPort := flag.String("smtp-port", BuildSMTPPort, "SMTP port (default: 465 for SSL, 587 for STARTTLS)") smtpUser := flag.String("smtp-user", BuildSMTPUser, "SMTP username") smtpPass := flag.String("smtp-pass", BuildSMTPPassword, "SMTP password") operation := flag.String("op", "list-folders", "Operation: list-folders, list-messages, get-message, send-email, delete-messages, move-messages, search") folder := flag.String("folder", "INBOX", "IMAP folder to operate on") from := flag.String("from", "", "Email sender address (for send-email)") to := flag.String("to", "", "Email recipient(s) (comma-separated, for send-email)") subject := flag.String("subject", "", "Email subject (for send-email)") body := flag.String("body", "", "Email body (for send-email)") attachments := flag.String("attachments", "", "Attachment file paths (comma-separated)") saveDir := flag.String("save-dir", ".", "Directory to save attachments") useSSL := flag.Bool("ssl", BuildUseSSL == "true", "Use SSL/TLS (default: true)") ignoreCerts := flag.Bool("ignore-certs", BuildIgnoreCerts == "true", "Ignore certificate validity") page := flag.Int("page", 1, "Page number (1-indexed)") pageSize := flag.Int("page-size", 25, "Messages per page") searchQuery := flag.String("query", "", "Search query (server-side search)") uids := flag.String("uids", "", "Message UIDs (comma-separated, for delete/move)") destFolder := flag.String("dest-folder", "", "Destination folder (for move-messages)") listAttachments := flag.Bool("list-attachments", false, "List message attachments only") saveAttachments := flag.Bool("save-attachments", false, "Save message attachments") flag.Parse() // Build config config := Config{ IMAPServer: *imapServer, IMAPPort: *imapPort, IMAPUser: *imapUser, IMAPPassword: *imapPass, SMTPServer: *smtpServer, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPassword: *smtpPass, UseSSL: *useSSL, IgnoreCerts: *ignoreCerts, } // Set default ports if config.IMAPPort == "" { if config.UseSSL { config.IMAPPort = "993" } else { config.IMAPPort = "143" } } if config.SMTPPort == "" { if config.UseSSL { config.SMTPPort = "465" } else { config.SMTPPort = "587" } } // Execute operation switch *operation { case "list-folders": err := listFolders(config) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "list-messages": if *folder == "" { fmt.Fprintln(os.Stderr, "Error: --folder is required") os.Exit(1) } err := listMessages(config, *folder, *page, *pageSize) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "get-message": if *folder == "" || *uids == "" { fmt.Fprintln(os.Stderr, "Error: --folder and --uids are required") os.Exit(1) } err := getMessage(config, *folder, *uids, *listAttachments, *saveAttachments, *saveDir) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "send-email": if *from == "" || *to == "" { fmt.Fprintln(os.Stderr, "Error: --from and --to are required") os.Exit(1) } var attachFiles []string if *attachments != "" { attachFiles = strings.Split(*attachments, ",") } err := sendEmail(config, *from, strings.Split(*to, ","), *subject, *body, attachFiles) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Println("Email sent successfully") case "delete-messages": if *folder == "" || *uids == "" { fmt.Fprintln(os.Stderr, "Error: --folder and --uids are required") os.Exit(1) } err := deleteMessages(config, *folder, *uids) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Println("Messages deleted successfully") case "move-messages": if *folder == "" || *uids == "" || *destFolder == "" { fmt.Fprintln(os.Stderr, "Error: --folder, --uids, and --dest-folder are required") os.Exit(1) } err := moveMessages(config, *folder, *uids, *destFolder) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Println("Messages moved successfully") case "search": if *folder == "" || *searchQuery == "" { fmt.Fprintln(os.Stderr, "Error: --folder and --query are required") os.Exit(1) } err := searchMessages(config, *folder, *searchQuery) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } default: fmt.Fprintf(os.Stderr, "Unknown operation: %s\n", *operation) os.Exit(1) } } // connectIMAP establishes a connection to IMAP server func connectIMAP(config Config) (*client.Client, error) { serverAddr := fmt.Sprintf("%s:%s", config.IMAPServer, config.IMAPPort) var c *client.Client var err error if config.UseSSL { tlsConfig := &tls.Config{ InsecureSkipVerify: config.IgnoreCerts, } c, err = client.DialTLS(serverAddr, tlsConfig) } else { c, err = client.Dial(serverAddr) if err == nil && !config.UseSSL { // Try STARTTLS tlsConfig := &tls.Config{ InsecureSkipVerify: config.IgnoreCerts, } err = c.StartTLS(tlsConfig) } } if err != nil { return nil, fmt.Errorf("failed to connect to IMAP server: %w", err) } // Login err = c.Login(config.IMAPUser, config.IMAPPassword) if err != nil { c.Close() return nil, fmt.Errorf("IMAP login failed: %w", err) } return c, nil } // listFolders lists all IMAP folders func listFolders(config Config) error { c, err := connectIMAP(config) if err != nil { return err } defer c.Close() // List mailboxes mailboxes := make(chan *imap.MailboxInfo, 10) done := make(chan error, 1) go func() { done <- c.List("", "*", mailboxes) }() fmt.Println("IMAP Folders:") for m := range mailboxes { fmt.Printf(" %s\n", m.Name) } if err := <-done; err != nil { return fmt.Errorf("failed to list folders: %w", err) } return nil } // listMessages lists messages in a folder with pagination func listMessages(config Config, folder string, page, pageSize int) error { c, err := connectIMAP(config) if err != nil { return err } defer c.Close() // Select mailbox mbox, err := c.Select(folder, false) if err != nil { return fmt.Errorf("failed to select folder %s: %w", folder, err) } // Calculate message range totalMessages := mbox.Messages if totalMessages == 0 { fmt.Println("No messages in folder") return nil } // Calculate pagination (1-indexed, newest first) offset := uint32((page - 1) * pageSize) start := totalMessages - offset end := start - uint32(pageSize) + 1 if end < 1 { end = 1 } if start > totalMessages { start = totalMessages } if start < end { fmt.Println("No messages on this page") return nil } seqset := new(imap.SeqSet) seqset.AddRange(start, end) // Fetch messages messages := make(chan *imap.Message, pageSize) done := make(chan error, 1) items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} go func() { done <- c.Fetch(seqset, items, messages) }() fmt.Printf("Messages in %s (Page %d of %d, showing %d-%d of %d):\n", folder, page, (int(totalMessages)+pageSize-1)/pageSize, end, start, totalMessages) fmt.Println() for msg := range messages { fmt.Printf("UID: %d\n", msg.Uid) fmt.Printf(" From: %s\n", msg.Envelope.From) fmt.Printf(" To: %s\n", msg.Envelope.To) if len(msg.Envelope.Subject) > 0 { fmt.Printf(" Subject: %s\n", msg.Envelope.Subject) } else { fmt.Printf(" Subject: (no subject)\n") } if !msg.Envelope.Date.IsZero() { fmt.Printf(" Date: %s\n", msg.Envelope.Date.Format(time.RFC1123)) } fmt.Println() } if err := <-done; err != nil { return fmt.Errorf("failed to fetch messages: %w", err) } return nil } // getMessage retrieves message content func getMessage(config Config, folder, uidsStr string, listAttachments, saveAttachments bool, saveDir string) error { c, err := connectIMAP(config) if err != nil { return err } defer c.Close() // Parse UIDs uidStrings := strings.Split(uidsStr, ",") uids := make([]uint32, 0, len(uidStrings)) for _, uidStr := range uidStrings { var uid uint32 _, err := fmt.Sscanf(strings.TrimSpace(uidStr), "%d", &uid) if err != nil { return fmt.Errorf("invalid UID %s: %w", uidStr, err) } uids = append(uids, uid) } // Select mailbox _, err = c.Select(folder, false) if err != nil { return fmt.Errorf("failed to select folder %s: %w", folder, err) } // Create save directory if needed if saveAttachments { err = os.MkdirAll(saveDir, 0755) if err != nil { return fmt.Errorf("failed to create save directory: %w", err) } } // Fetch messages for _, uid := range uids { seqset := new(imap.SeqSet) seqset.AddNum(uid) // Determine what to fetch items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} if listAttachments || saveAttachments { items = append(items, imap.FetchBodyStructure) } messages := make(chan *imap.Message, 1) err := c.UidFetch(seqset, items, messages) if err != nil { return fmt.Errorf("failed to fetch message %d: %w", uid, err) } msg := <-messages if msg == nil { fmt.Printf("Message UID %d not found\n", uid) continue } fmt.Printf("UID: %d\n", uid) fmt.Printf(" From: %s\n", msg.Envelope.From) fmt.Printf(" Subject: %s\n", msg.Envelope.Subject) // Process attachments if listAttachments || saveAttachments { if msg.BodyStructure == nil { fmt.Println(" No body structure available") continue } attachments := extractAttachmentInfo(msg.BodyStructure, "") if len(attachments) == 0 { fmt.Println(" No attachments found") } if listAttachments { fmt.Printf(" Attachments (%d):\n", len(attachments)) for i, att := range attachments { fmt.Printf(" %d. %s (%s) - Part: %s\n", i+1, att.Filename, att.ContentType, att.PartPath) } } if saveAttachments { fmt.Printf(" Saving attachments to %s:\n", saveDir) for _, att := range attachments { // Fetch the attachment body part if att.PartPath == "" { fmt.Printf(" Skipping %s: no part path\n", att.Filename) continue } // Parse part path to create BodySectionName section := imap.BodySectionName{} pathParts := strings.Split(att.PartPath, ".") section.Path = make([]int, len(pathParts)) for i, p := range pathParts { _, err := fmt.Sscanf(p, "%d", §ion.Path[i]) if err != nil { fmt.Printf(" Error parsing part path for %s: %v\n", att.Filename, err) continue } } items := []imap.FetchItem{section.FetchItem()} // Fetch this specific message again with the body part msgSeqSet := new(imap.SeqSet) msgSeqSet.AddNum(uid) attachMessages := make(chan *imap.Message, 1) err := c.UidFetch(msgSeqSet, items, attachMessages) if err != nil { fmt.Printf(" Error fetching %s: %v\n", att.Filename, err) continue } attachMsg := <-attachMessages if attachMsg == nil { fmt.Printf(" Error: attachment not found for %s\n", att.Filename) continue } // Find the literal data for this section var reader io.Reader for _, r := range attachMsg.Body { reader = r break } if reader == nil { fmt.Printf(" Error: no data for attachment %s\n", att.Filename) continue } // Read attachment data data, err := io.ReadAll(reader) if err != nil { fmt.Printf(" Error reading %s: %v\n", att.Filename, err) continue } // Save to disk safeFilename := filepath.Join(saveDir, att.Filename) err = os.WriteFile(safeFilename, data, 0644) if err != nil { fmt.Printf(" Error saving %s: %v\n", att.Filename, err) } else { fmt.Printf(" Saved: %s (%d bytes)\n", att.Filename, len(data)) } } } } else { // Print body text - fetch the full message body bodyItems := []imap.FetchItem{imap.FetchBody} bodySeqSet := new(imap.SeqSet) bodySeqSet.AddNum(uid) bodyMessages := make(chan *imap.Message, 1) err := c.UidFetch(bodySeqSet, bodyItems, bodyMessages) if err != nil { fmt.Printf(" Error fetching message body: %v\n", err) } else { bodyMsg := <-bodyMessages if bodyMsg != nil { for _, section := range bodyMsg.Body { reader := section _, err := io.Copy(os.Stdout, reader) if err != nil { fmt.Printf(" Error reading message body: %v\n", err) } } } } } fmt.Println() } return nil } // Attachment represents an email attachment type Attachment struct { Filename string ContentType string Data []byte Size int PartPath string // IMAP part path (e.g., "1.2") } // extractAttachmentInfo extracts attachment information from BodyStructure func extractAttachmentInfo(bs *imap.BodyStructure, partPath string) []Attachment { var attachments []Attachment if bs == nil { return attachments } // Build part path for this part var currentPath string if partPath == "" { // Root part doesn't have a path in IMAP currentPath = "" } else { currentPath = partPath } // Check if this part is an attachment filename := bs.DispositionParams["filename"] if filename == "" { filename = bs.Params["name"] } if filename != "" && (strings.HasPrefix(bs.Disposition, "attachment") || strings.HasPrefix(bs.MIMEType, "application/") || strings.HasPrefix(bs.MIMEType, "image/")) { // This is an attachment attachments = append(attachments, Attachment{ Filename: filename, ContentType: bs.MIMEType, PartPath: currentPath, }) } // Recursively process multipart if len(bs.Parts) > 0 { for i, part := range bs.Parts { var subPath string if currentPath == "" { subPath = fmt.Sprintf("%d", i+1) } else { subPath = fmt.Sprintf("%s.%d", currentPath, i+1) } attachments = append(attachments, extractAttachmentInfo(part, subPath)...) } } return attachments } // extractAttachments extracts attachments from a message (deprecated, use extractAttachmentInfo) func extractAttachments(msg *imap.Message) ([]Attachment, error) { return []Attachment{}, nil // Deprecated } // deleteMessages deletes messages by UID func deleteMessages(config Config, folder, uidsStr string) error { c, err := connectIMAP(config) if err != nil { return err } defer c.Close() // Select mailbox mbox, err := c.Select(folder, false) if err != nil { return fmt.Errorf("failed to select folder %s: %w", folder, err) } // Check if folder is read-only if mbox.ReadOnly { return fmt.Errorf("folder is read-only") } // Parse UIDs uidStrings := strings.Split(uidsStr, ",") seqset := new(imap.SeqSet) for _, uidStr := range uidStrings { var uid uint32 _, err := fmt.Sscanf(strings.TrimSpace(uidStr), "%d", &uid) if err != nil { return fmt.Errorf("invalid UID %s: %w", uidStr, err) } seqset.AddNum(uid) } // Delete messages if err := c.Store(seqset, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{imap.DeletedFlag}, nil); err != nil { return fmt.Errorf("failed to mark messages for deletion: %w", err) } if err := c.Expunge(nil); err != nil { return fmt.Errorf("failed to expunge messages: %w", err) } return nil } // moveMessages moves messages between folders func moveMessages(config Config, folder, uidsStr, destFolder string) error { c, err := connectIMAP(config) if err != nil { return err } defer c.Close() // Parse UIDs uidStrings := strings.Split(uidsStr, ",") uids := make([]uint32, 0, len(uidStrings)) for _, uidStr := range uidStrings { var uid uint32 _, err := fmt.Sscanf(strings.TrimSpace(uidStr), "%d", &uid) if err != nil { return fmt.Errorf("invalid UID %s: %w", uidStr, err) } uids = append(uids, uid) } // Select source mailbox mbox, err := c.Select(folder, false) if err != nil { return fmt.Errorf("failed to select folder %s: %w", folder, err) } // Check if folder is read-only if mbox.ReadOnly { return fmt.Errorf("folder is read-only") } // Check for MOVE capability caps, err := c.Capability() if err != nil { return fmt.Errorf("failed to get capabilities: %w", err) } if caps["MOVE"] { // Use MOVE extension if available seqset := new(imap.SeqSet) for _, uid := range uids { seqset.AddNum(uid) } if err := c.UidMove(seqset, destFolder); err != nil { return fmt.Errorf("failed to move messages: %w", err) } } else { // Fallback: copy then delete seqset := new(imap.SeqSet) for _, uid := range uids { seqset.AddNum(uid) } // Copy to destination if err := c.UidCopy(seqset, destFolder); err != nil { return fmt.Errorf("failed to copy messages: %w", err) } // Mark as deleted and expunge if err := c.Store(seqset, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{imap.DeletedFlag}, nil); err != nil { return fmt.Errorf("failed to mark messages for deletion: %w", err) } if err := c.Expunge(nil); err != nil { return fmt.Errorf("failed to expunge messages: %w", err) } } return nil } // searchMessages performs server-side search func searchMessages(config Config, folder, query string) error { c, err := connectIMAP(config) if err != nil { return err } defer c.Close() // Select mailbox _, err = c.Select(folder, false) if err != nil { return fmt.Errorf("failed to select folder %s: %w", folder, err) } // Build search criteria (simple keyword search for now) criteria := imap.NewSearchCriteria() criteria.Text = []string{query} ids, err := c.Search(criteria) if err != nil { return fmt.Errorf("search failed: %w", err) } if len(ids) == 0 { fmt.Printf("No messages matching query: %s\n", query) return nil } fmt.Printf("Found %d messages matching '%s' in %s:\n", len(ids), query, folder) // Fetch results seqset := new(imap.SeqSet) seqset.AddNum(ids...) messages := make(chan *imap.Message, len(ids)) done := make(chan error, 1) items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} go func() { done <- c.Fetch(seqset, items, messages) }() for msg := range messages { fmt.Printf("UID: %d\n", msg.Uid) fmt.Printf(" From: %s\n", msg.Envelope.From) if len(msg.Envelope.Subject) > 0 { fmt.Printf(" Subject: %s\n", msg.Envelope.Subject) } else { fmt.Printf(" Subject: (no subject)\n") } if !msg.Envelope.Date.IsZero() { fmt.Printf(" Date: %s\n", msg.Envelope.Date.Format(time.RFC1123)) } fmt.Println() } if err := <-done; err != nil { return fmt.Errorf("failed to fetch search results: %w", err) } return nil } // sendEmail sends an email via SMTP func sendEmail(config Config, from string, to []string, subject, body string, attachments []string) error { smtpAddr := fmt.Sprintf("%s:%s", config.SMTPServer, config.SMTPPort) // Build message var msg strings.Builder msg.WriteString(fmt.Sprintf("From: %s\n", from)) msg.WriteString(fmt.Sprintf("To: %s\n", strings.Join(to, ", "))) msg.WriteString(fmt.Sprintf("Subject: %s\n", subject)) msg.WriteString("MIME-Version: 1.0\n") if len(attachments) == 0 { msg.WriteString("Content-Type: text/plain; charset=utf-8\n") msg.WriteString("\n") msg.WriteString(body) } else { // Multipart with attachments boundary := "NextcloudMail_" + fmt.Sprintf("%d", time.Now().UnixNano()) msg.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\n", boundary)) msg.WriteString("\n") // Text body msg.WriteString(fmt.Sprintf("--%s\n", boundary)) msg.WriteString("Content-Type: text/plain; charset=utf-8\n") msg.WriteString("\n") msg.WriteString(body) msg.WriteString("\n") // Attachments for _, file := range attachments { data, err := os.ReadFile(file) if err != nil { return fmt.Errorf("failed to read attachment %s: %w", file, err) } filename := filepath.Base(file) msg.WriteString(fmt.Sprintf("--%s\n", boundary)) msg.WriteString("Content-Type: application/octet-stream\n") msg.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\n", filename)) msg.WriteString("Content-Transfer-Encoding: base64\n") msg.WriteString("\n") msg.WriteString(encodeBase64(data)) msg.WriteString("\n") } msg.WriteString(fmt.Sprintf("--%s--\n", boundary)) } // Connect and send var auth smtp.Auth if config.SMTPUser != "" && config.SMTPPassword != "" { auth = smtp.PlainAuth("", config.SMTPUser, config.SMTPPassword, config.SMTPServer) } if config.UseSSL { tlsConfig := &tls.Config{ InsecureSkipVerify: config.IgnoreCerts, ServerName: config.SMTPServer, } // Connect via TLS conn, err := tls.Dial("tcp", smtpAddr, tlsConfig) if err != nil { return fmt.Errorf("failed to connect via TLS: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, config.SMTPServer) if err != nil { return fmt.Errorf("failed to create SMTP client: %w", err) } defer client.Quit() if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("SMTP authentication failed: %w", err) } } if err := client.Mail(from); err != nil { return fmt.Errorf("failed to set sender: %w", err) } for _, addr := range to { if err := client.Rcpt(addr); err != nil { return fmt.Errorf("failed to add recipient %s: %w", addr, err) } } wc, err := client.Data() if err != nil { return fmt.Errorf("failed to send data: %w", err) } defer wc.Close() _, err = wc.Write([]byte(msg.String())) if err != nil { return fmt.Errorf("failed to write message: %w", err) } } else { // Non-SSL connection with STARTTLS err := smtp.SendMail(smtpAddr, auth, from, to, []byte(msg.String())) if err != nil { return fmt.Errorf("failed to send email: %w", err) } } return nil } // encodeBase64 encodes data to base64 with line wrapping func encodeBase64(data []byte) string { const maxLineLen = 76 encoded := base64.StdEncoding.EncodeToString(data) // Add line breaks every maxLineLen characters var result strings.Builder for i := 0; i < len(encoded); i += maxLineLen { end := i + maxLineLen if end > len(encoded) { end = len(encoded) } result.WriteString(encoded[i:end]) result.WriteString("\n") } return result.String() }