- CLI tools: nextcloud-client, nextcloud-contacts, nextcloud-calendar, nextcloud-mail - Build script with compile-time credentials - Skills for all four tools - Email tool supports IMAP/SMTP with attachment download
883 lines
23 KiB
Go
883 lines
23 KiB
Go
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()
|
|
}
|