├── .vscode └── settings.json ├── .gitignore ├── .golangci.yml ├── flags.go ├── util_test.go ├── doc.go ├── util.go ├── auth.go ├── .github ├── workflows │ └── go.yml └── dependabot.yml ├── go.mod ├── vars.go ├── LICENSE ├── examples ├── basic_connection │ └── main.go ├── oauth2_connection │ └── main.go ├── folders │ └── main.go ├── fetch_emails │ └── main.go ├── idle_monitoring │ └── main.go ├── literal_search │ └── main.go ├── complete_example │ └── main.go ├── search │ └── main.go ├── email_operations │ └── main.go └── error_handling │ └── main.go ├── exec.go ├── logger.go ├── go.sum ├── message_test.go ├── AGENTS.md ├── idle.go ├── conn.go ├── parse_test.go ├── folder.go ├── folder_test.go ├── parse.go ├── auth_test.go ├── message.go └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | imap 2 | imap.exe 3 | .devcontainer 4 | .vscode/settings.json 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | timeout: 3m 5 | tests: false 6 | 7 | linters: 8 | enable: 9 | - govet 10 | - staticcheck 11 | - ineffassign 12 | - unused 13 | 14 | issues: 15 | # Keep output manageable; raise if you want more strictness later. 16 | max-issues-per-linter: 0 17 | max-same-issues: 0 18 | 19 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | // FlagSet represents the action to take on a flag 4 | type FlagSet int 5 | 6 | const ( 7 | FlagUnset FlagSet = iota 8 | FlagAdd 9 | FlagRemove 10 | ) 11 | 12 | // Flags represents standard IMAP message flags 13 | type Flags struct { 14 | Seen FlagSet 15 | Answered FlagSet 16 | Flagged FlagSet 17 | Deleted FlagSet 18 | Draft FlagSet 19 | Keywords map[string]bool 20 | } 21 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMakeIMAPLiteral(t *testing.T) { 8 | tests := []struct { 9 | input string 10 | expected string 11 | }{ 12 | {"test", "{4}\r\ntest"}, 13 | {"тест", "{8}\r\nтест"}, 14 | {"测试", "{6}\r\n测试"}, 15 | {"😀👍", "{8}\r\n😀👍"}, 16 | {"Prüfung", "{8}\r\nPrüfung"}, 17 | {"", "{0}\r\n"}, 18 | } 19 | 20 | for _, test := range tests { 21 | got := MakeIMAPLiteral(test.input) 22 | if got != test.expected { 23 | t.Errorf("MakeIMAPLiteral(%q) = %q, want %q", test.input, got, test.expected) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package imap provides a simple, pragmatic IMAP client for Go. 2 | // 3 | // It focuses on the handful of operations most applications need: 4 | // 5 | // - Connecting over TLS (STARTTLS not required) 6 | // - Authenticating with LOGIN or XOAUTH2 (OAuth 2.0) 7 | // - Selecting/Examining folders, searching (UID SEARCH), and fetching messages 8 | // - Moving messages, setting flags, deleting + expunging 9 | // - IMAP IDLE with callbacks for EXISTS/EXPUNGE/FETCH 10 | // - Automatic reconnect with re-authentication and folder restore 11 | // 12 | // The API is intentionally small and easy to adopt without pulling in a full 13 | // IMAP stack. See the README for end‑to‑end examples and guidance. 14 | package imap 15 | 16 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import "fmt" 4 | 5 | // dropNl removes trailing newline characters from a byte slice 6 | func dropNl(b []byte) []byte { 7 | if len(b) >= 1 && b[len(b)-1] == '\n' { 8 | if len(b) >= 2 && b[len(b)-2] == '\r' { 9 | return b[:len(b)-2] 10 | } else { 11 | return b[:len(b)-1] 12 | } 13 | } 14 | return b 15 | } 16 | 17 | // MakeIMAPLiteral generates IMAP literal syntax for non-ASCII strings. 18 | // It returns a string in the format "{bytecount}\r\ntext" where bytecount 19 | // is the number of bytes (not characters) in the input string. 20 | // This is useful for search queries with non-ASCII characters. 21 | // Example: MakeIMAPLiteral("тест") returns "{8}\r\nтест" 22 | func MakeIMAPLiteral(s string) string { 23 | return fmt.Sprintf("{%d}\r\n%s", len([]byte(s)), s) 24 | } 25 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sqs/go-xoauth2" 7 | ) 8 | 9 | // Authenticate performs XOAUTH2 authentication using an access token 10 | func (d *Dialer) Authenticate(user string, accessToken string) (err error) { 11 | b64 := xoauth2.XOAuth2String(user, accessToken) 12 | // Don't retry authentication - auth failures should not trigger reconnection 13 | _, err = d.Exec(fmt.Sprintf("AUTHENTICATE XOAUTH2 %s", b64), false, 0, nil) 14 | return err 15 | } 16 | 17 | // Login performs LOGIN authentication using username and password 18 | func (d *Dialer) Login(username string, password string) (err error) { 19 | // Don't retry authentication - auth failures should not trigger reconnection 20 | _, err = d.Exec(fmt.Sprintf(`LOGIN "%s" "%s"`, AddSlashes.Replace(username), AddSlashes.Replace(password)), false, 0, nil) 21 | return err 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go CI 2 | 3 | on: 4 | pull_request: # runs on open, sync (new commits), and reopen 5 | branches: [ master ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v6 16 | with: 17 | go-version: '^1.25' 18 | cache: true # module & build cache 19 | 20 | - name: Vet 21 | run: go vet ./... 22 | 23 | - name: Test (race + coverage) 24 | run: go test -race -coverprofile=coverage.out ./... 25 | 26 | - name: Upload coverage (artifact) 27 | uses: actions/upload-artifact@v5 28 | with: 29 | name: coverage 30 | path: coverage.out 31 | 32 | - name: GolangCI-Lint 33 | uses: golangci/golangci-lint-action@v9 34 | with: 35 | version: latest 36 | args: --timeout=3m 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrianLeishman/go-imap 2 | 3 | go 1.25 4 | 5 | toolchain go1.25.1 6 | 7 | require ( 8 | github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 9 | github.com/davecgh/go-spew v1.1.1 10 | github.com/dustin/go-humanize v1.0.1 11 | github.com/jhillyerd/enmime v1.3.0 12 | github.com/rs/xid v1.6.0 13 | github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 14 | golang.org/x/net v0.47.0 15 | ) 16 | 17 | require ( 18 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect 19 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect 20 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect 21 | github.com/mattn/go-runewidth v0.0.15 // indirect 22 | github.com/olekukonko/tablewriter v0.0.5 // indirect 23 | github.com/pkg/errors v0.9.1 // indirect 24 | github.com/rivo/uniseg v0.4.4 // indirect 25 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 26 | golang.org/x/text v0.31.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /vars.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | // String replacers for escaping/unescaping quotes 9 | var ( 10 | AddSlashes = strings.NewReplacer(`"`, `\"`) 11 | RemoveSlashes = strings.NewReplacer(`\"`, `"`) 12 | ) 13 | 14 | // Verbose outputs every command and its response with the IMAP server 15 | var Verbose = false 16 | 17 | // SkipResponses skips printing server responses in verbose mode 18 | var SkipResponses = false 19 | 20 | var RetryCount = 10 21 | 22 | // DialTimeout defines how long to wait when establishing a new connection. 23 | // Zero means no timeout. 24 | var DialTimeout time.Duration 25 | 26 | // CommandTimeout defines how long to wait for a command to complete. 27 | // Zero means no timeout. 28 | var CommandTimeout time.Duration 29 | 30 | // TLSSkipVerify disables certificate verification when establishing new 31 | // connections. Use with caution; skipping verification exposes the 32 | // connection to man-in-the-middle attacks. 33 | var TLSSkipVerify bool 34 | 35 | var lastResp string 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Brian Leishman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Go modules (gomod) 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "08:00" 10 | timezone: "America/New_York" 11 | open-pull-requests-limit: 5 12 | commit-message: 13 | prefix: "chore(deps):" 14 | include: "scope" 15 | labels: 16 | - "dependencies" 17 | - "go" 18 | groups: 19 | go-minor-and-patch: 20 | patterns: 21 | - "*" 22 | update-types: 23 | - "minor" 24 | - "patch" 25 | 26 | # GitHub Actions 27 | - package-ecosystem: "github-actions" 28 | directory: "/" 29 | schedule: 30 | interval: "weekly" 31 | day: "monday" 32 | time: "08:00" 33 | timezone: "America/New_York" 34 | open-pull-requests-limit: 5 35 | commit-message: 36 | prefix: "chore(actions):" 37 | labels: 38 | - "dependencies" 39 | - "ci" 40 | groups: 41 | actions-minor-and-patch: 42 | patterns: 43 | - "*" 44 | update-types: 45 | - "minor" 46 | - "patch" 47 | -------------------------------------------------------------------------------- /examples/basic_connection/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | imap.Verbose = false // Set to true to see all IMAP commands/responses 13 | imap.RetryCount = 3 // Number of retries for failed commands 14 | imap.DialTimeout = 10 * time.Second // Connection timeout 15 | imap.CommandTimeout = 30 * time.Second // Command timeout 16 | 17 | // For self-signed certificates (use with caution!) 18 | // imap.TLSSkipVerify = true 19 | 20 | // Connect with standard LOGIN authentication 21 | // Replace with your actual credentials and server 22 | m, err := imap.New("username", "password", "mail.server.com", 993) 23 | if err != nil { 24 | log.Fatalf("Failed to connect: %v", err) 25 | } 26 | defer func() { 27 | if err := m.Close(); err != nil { 28 | log.Printf("Failed to close connection: %v", err) 29 | } 30 | }() 31 | 32 | // Quick test - list folders 33 | folders, err := m.GetFolders() 34 | if err != nil { 35 | log.Fatalf("Failed to get folders: %v", err) 36 | } 37 | 38 | fmt.Printf("Connected! Found %d folders\n", len(folders)) 39 | fmt.Println("\nAvailable folders:") 40 | for _, folder := range folders { 41 | fmt.Printf(" - %s\n", folder) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/oauth2_connection/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | imap.DialTimeout = 10 * time.Second 13 | imap.CommandTimeout = 30 * time.Second 14 | 15 | // OAuth2 access token - you need to obtain this from your OAuth2 flow 16 | // For Gmail: https://developers.google.com/gmail/api/auth/about-auth 17 | // For Office 365: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow 18 | accessToken := "your-oauth2-access-token" 19 | 20 | // Connect with OAuth2 (Gmail example) 21 | m, err := imap.NewWithOAuth2("user@example.com", accessToken, "imap.gmail.com", 993) 22 | if err != nil { 23 | log.Fatalf("Failed to connect with OAuth2: %v", err) 24 | } 25 | defer func() { 26 | if err := m.Close(); err != nil { 27 | log.Printf("Failed to close connection: %v", err) 28 | } 29 | }() 30 | 31 | // The OAuth2 connection works exactly like LOGIN after authentication 32 | if err := m.SelectFolder("INBOX"); err != nil { 33 | log.Fatalf("Failed to select INBOX: %v", err) 34 | } 35 | 36 | unreadUIDs, err := m.GetUIDs("UNSEEN") 37 | if err != nil { 38 | log.Fatalf("Failed to search for unread emails: %v", err) 39 | } 40 | 41 | fmt.Printf("Connected via OAuth2!\n") 42 | fmt.Printf("You have %d unread emails in INBOX\n", len(unreadUIDs)) 43 | } 44 | -------------------------------------------------------------------------------- /exec.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | retry "github.com/StirlingMarketingGroup/go-retry" 13 | "github.com/rs/xid" 14 | ) 15 | 16 | // Exec executes an IMAP command with retry logic and response building 17 | func (d *Dialer) Exec(command string, buildResponse bool, retryCount int, processLine func(line []byte) error) (response string, err error) { 18 | var resp strings.Builder 19 | err = retry.Retry(func() (err error) { 20 | tag := []byte(fmt.Sprintf("%X", xid.New())) 21 | 22 | if CommandTimeout != 0 { 23 | _ = d.conn.SetDeadline(time.Now().Add(CommandTimeout)) 24 | defer func() { _ = d.conn.SetDeadline(time.Time{}) }() 25 | } 26 | 27 | c := fmt.Sprintf("%s %s\r\n", tag, command) 28 | 29 | if Verbose { 30 | sanitized := strings.ReplaceAll(strings.TrimSpace(c), fmt.Sprintf(`"%s"`, d.Password), `"****"`) 31 | debugLog(d.ConnNum, d.Folder, "sending command", "command", sanitized) 32 | } 33 | 34 | _, err = d.conn.Write([]byte(c)) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | r := bufio.NewReader(d.conn) 40 | 41 | if buildResponse { 42 | resp = strings.Builder{} 43 | } 44 | var line []byte 45 | for err == nil { 46 | line, err = r.ReadBytes('\n') 47 | for { 48 | if a := atom.Find(dropNl(line)); a != nil { 49 | // fmt.Printf("%s\n", a) 50 | var n int 51 | n, err = strconv.Atoi(string(a[1 : len(a)-1])) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | buf := make([]byte, n) 57 | _, err = io.ReadFull(r, buf) 58 | if err != nil { 59 | return err 60 | } 61 | line = append(line, buf...) 62 | 63 | buf, err = r.ReadBytes('\n') 64 | if err != nil { 65 | return err 66 | } 67 | line = append(line, buf...) 68 | 69 | continue 70 | } 71 | break 72 | } 73 | 74 | if Verbose && !SkipResponses { 75 | debugLog(d.ConnNum, d.Folder, "server response", "response", string(dropNl(line))) 76 | } 77 | 78 | // if strings.Contains(string(line), "--00000000000030095105741e7f1f") { 79 | // f, _ := ioutil.TempFile("", "") 80 | // ioutil.WriteFile(f.Name(), line, 0777) 81 | // fmt.Println(f.Name()) 82 | // } 83 | 84 | // XID project is returning 40-byte tags. The code was originally hardcoded 16 digits. 85 | taglen := len(tag) 86 | oklen := 3 87 | if len(line) >= taglen+oklen && bytes.Equal(line[:taglen], tag) { 88 | if !bytes.Equal(line[taglen+1:taglen+oklen], []byte("OK")) { 89 | err = fmt.Errorf("imap command failed: %s", line[taglen+oklen+1:]) 90 | return err 91 | } 92 | break 93 | } 94 | 95 | if processLine != nil { 96 | if err = processLine(line); err != nil { 97 | return err 98 | } 99 | } 100 | if buildResponse { 101 | resp.Write(line) 102 | } 103 | } 104 | return err 105 | }, retryCount, func(err error) error { 106 | if Verbose { 107 | warnLog(d.ConnNum, d.Folder, "command failed, closing connection", "error", err) 108 | } 109 | _ = d.Close() 110 | return nil 111 | }, func() error { 112 | return d.Reconnect() 113 | }) 114 | if err != nil { 115 | errorLog(d.ConnNum, d.Folder, "command retries exhausted", "error", err) 116 | return "", err 117 | } 118 | 119 | if buildResponse { 120 | if resp.Len() != 0 { 121 | lastResp = resp.String() 122 | return lastResp, nil 123 | } 124 | return "", nil 125 | } 126 | return response, err 127 | } 128 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "sync/atomic" 7 | ) 8 | 9 | // Logger defines the minimal logging interface used by the IMAP client. 10 | // 11 | // Implementations must be safe for concurrent use. 12 | type Logger interface { 13 | Debug(msg string, args ...any) 14 | Info(msg string, args ...any) 15 | Warn(msg string, args ...any) 16 | Error(msg string, args ...any) 17 | WithAttrs(args ...any) Logger 18 | } 19 | 20 | var globalLogger atomic.Value // stores Logger 21 | 22 | func init() { 23 | globalLogger.Store(defaultLogger()) 24 | } 25 | 26 | // defaultLogger returns the library's default slog-based logger. 27 | func defaultLogger() Logger { 28 | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}) 29 | return SlogLogger(slog.New(handler)).WithAttrs("component", "imap/agent") 30 | } 31 | 32 | // SetLogger replaces the global logger used by the package. Passing nil 33 | // restores the built-in slog logger. 34 | func SetLogger(logger Logger) { 35 | if logger == nil { 36 | globalLogger.Store(defaultLogger()) 37 | return 38 | } 39 | globalLogger.Store(logger.WithAttrs("component", "imap/agent")) 40 | } 41 | 42 | // SetSlogLogger is a convenience helper for using a *slog.Logger directly. 43 | func SetSlogLogger(logger *slog.Logger) { 44 | SetLogger(SlogLogger(logger)) 45 | } 46 | 47 | // SlogLogger adapts a *slog.Logger to the Logger interface. 48 | func SlogLogger(logger *slog.Logger) Logger { 49 | if logger == nil { 50 | return nil 51 | } 52 | return slogAdapter{logger: logger} 53 | } 54 | 55 | type slogAdapter struct { 56 | logger *slog.Logger 57 | } 58 | 59 | func (s slogAdapter) Debug(msg string, args ...any) { s.logger.Debug(msg, args...) } 60 | 61 | func (s slogAdapter) Info(msg string, args ...any) { s.logger.Info(msg, args...) } 62 | 63 | func (s slogAdapter) Warn(msg string, args ...any) { s.logger.Warn(msg, args...) } 64 | 65 | func (s slogAdapter) Error(msg string, args ...any) { s.logger.Error(msg, args...) } 66 | 67 | func (s slogAdapter) WithAttrs(args ...any) Logger { 68 | return slogAdapter{logger: s.logger.With(args...)} 69 | } 70 | 71 | // getLogger returns the currently configured logger. 72 | func getLogger() Logger { 73 | if v := globalLogger.Load(); v != nil { 74 | if l, ok := v.(Logger); ok { 75 | return l 76 | } 77 | } 78 | // Fallback for safety if init() was skipped (e.g., in tests). 79 | l := defaultLogger() 80 | globalLogger.Store(l) 81 | return l 82 | } 83 | 84 | // connectionLogger adds per-connection context to the configured logger. 85 | func connectionLogger(connNum int, folder string) Logger { 86 | logger := getLogger() 87 | // connNum < 0 signals that the caller does not have an active connection 88 | // context (for example, package-level diagnostics). 89 | if connNum < 0 && folder == "" { 90 | return logger 91 | } 92 | 93 | args := []any{"conn", connNum} 94 | if folder != "" { 95 | args = append(args, "mailbox", folder) 96 | } 97 | return logger.WithAttrs(args...) 98 | } 99 | 100 | // debugLog emits a debug log entry when verbose logging is enabled. 101 | func debugLog(connNum int, folder string, msg string, args ...any) { 102 | if !Verbose { 103 | return 104 | } 105 | connectionLogger(connNum, folder).Debug(msg, args...) 106 | } 107 | 108 | func warnLog(connNum int, folder string, msg string, args ...any) { 109 | connectionLogger(connNum, folder).Warn(msg, args...) 110 | } 111 | 112 | func errorLog(connNum int, folder string, msg string, args ...any) { 113 | connectionLogger(connNum, folder).Error(msg, args...) 114 | } 115 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 h1:y1OlgL2twHNQGJ4OTHhvVLebgDCwP4pttmZc2w4UAz8= 2 | github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893/go.mod h1:RHK0VFlYDZQeNFg4C2dp7cPE6urfbpgyEZIGxa9f5zw= 3 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= 4 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 8 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 9 | github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= 10 | github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 11 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= 12 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= 13 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= 14 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 15 | github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw= 16 | github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs= 17 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 18 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 19 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 20 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 21 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 22 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 23 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 27 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 28 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 29 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 30 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 31 | github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 h1:KCgKdj+ha4CgnVHIiJYGKzgZk3HfCc6XssESfOa6atM= 32 | github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56/go.mod h1:ghDEBrT4oFcM4rv18bzcZaAWXbHPGpDa4e2hh9oXL8A= 33 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= 34 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 35 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 36 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 37 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 38 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 39 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 40 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 41 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 42 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 43 | -------------------------------------------------------------------------------- /examples/folders/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | imap "github.com/BrianLeishman/go-imap" 8 | ) 9 | 10 | func main() { 11 | // Connect to server 12 | m, err := imap.New("username", "password", "mail.server.com", 993) 13 | if err != nil { 14 | log.Fatalf("Failed to connect: %v", err) 15 | } 16 | defer func() { 17 | if err := m.Close(); err != nil { 18 | log.Printf("Failed to close connection: %v", err) 19 | } 20 | }() 21 | 22 | // List all folders 23 | folders, err := m.GetFolders() 24 | if err != nil { 25 | log.Fatalf("Failed to get folders: %v", err) 26 | } 27 | 28 | fmt.Println("Available folders:") 29 | for _, folder := range folders { 30 | fmt.Printf(" - %s\n", folder) 31 | } 32 | // Example output: 33 | // - INBOX 34 | // - Sent 35 | // - Drafts 36 | // - Trash 37 | // - INBOX/Receipts 38 | // - INBOX/Important 39 | // - [Gmail]/All Mail 40 | // - [Gmail]/Spam 41 | 42 | fmt.Println("\n--- Folder Operations ---") 43 | 44 | // Select a folder for operations (read-write mode) 45 | err = m.SelectFolder("INBOX") 46 | if err != nil { 47 | log.Fatalf("Failed to select INBOX: %v", err) 48 | } 49 | fmt.Println("Selected INBOX in read-write mode") 50 | 51 | // Get message count in current folder 52 | allUIDs, err := m.GetUIDs("ALL") 53 | if err != nil { 54 | log.Fatalf("Failed to get message count: %v", err) 55 | } 56 | fmt.Printf("INBOX contains %d messages\n", len(allUIDs)) 57 | 58 | // Select folder in read-only mode 59 | err = m.ExamineFolder("Sent") 60 | if err != nil { 61 | log.Fatalf("Failed to examine Sent folder: %v", err) 62 | } 63 | fmt.Println("\nExamined Sent folder in read-only mode") 64 | 65 | sentUIDs, err := m.GetUIDs("ALL") 66 | if err != nil { 67 | log.Fatalf("Failed to get sent message count: %v", err) 68 | } 69 | fmt.Printf("Sent folder contains %d messages\n", len(sentUIDs)) 70 | 71 | fmt.Println("\n--- Email Counts ---") 72 | 73 | // Get total email count across all folders (traditional approach) 74 | totalCount, err := m.GetTotalEmailCount() 75 | if err != nil { 76 | fmt.Printf("Traditional count failed: %v\n", err) 77 | fmt.Println("This might happen with Gmail or other providers that have inaccessible system folders") 78 | } else { 79 | fmt.Printf("Total emails in all folders: %d\n", totalCount) 80 | } 81 | 82 | // Get total email count with robust error handling 83 | safeCount, folderErrors, err := m.GetTotalEmailCountSafe() 84 | if err != nil { 85 | log.Fatalf("Failed to get safe total email count: %v", err) 86 | } 87 | fmt.Printf("Total emails (safe count): %d\n", safeCount) 88 | 89 | if len(folderErrors) > 0 { 90 | fmt.Printf("Note: %d folders had errors:\n", len(folderErrors)) 91 | for _, folderErr := range folderErrors { 92 | fmt.Printf(" - %v\n", folderErr) 93 | } 94 | } 95 | 96 | // Get count excluding certain folders (safe version) 97 | excludedFolders := []string{"Trash", "[Gmail]/Spam", "Junk", "Deleted"} 98 | count, folderErrors, err := m.GetTotalEmailCountSafeExcluding(excludedFolders) 99 | if err != nil { 100 | log.Fatalf("Failed to get filtered email count: %v", err) 101 | } 102 | fmt.Printf("Total emails (excluding spam/trash): %d\n", count) 103 | 104 | if len(folderErrors) > 0 { 105 | fmt.Printf("Folders with errors during exclusion count: %d\n", len(folderErrors)) 106 | } 107 | 108 | // Calculate percentage 109 | if safeCount > 0 { 110 | percentage := float64(count) / float64(safeCount) * 100 111 | fmt.Printf("That's %.1f%% of your total emails\n", percentage) 112 | } 113 | 114 | fmt.Println("\n--- Detailed Folder Statistics ---") 115 | 116 | // Get detailed statistics for each folder 117 | stats, err := m.GetFolderStats() 118 | if err != nil { 119 | log.Fatalf("Failed to get folder statistics: %v", err) 120 | } 121 | 122 | fmt.Printf("Found %d folders:\n", len(stats)) 123 | successfulFolders := 0 124 | totalEmails := 0 125 | 126 | for _, stat := range stats { 127 | if stat.Error != nil { 128 | fmt.Printf(" %-30s [ERROR]: %v\n", stat.Name, stat.Error) 129 | } else { 130 | fmt.Printf(" %-30s %5d emails, max UID: %d\n", stat.Name, stat.Count, stat.MaxUID) 131 | successfulFolders++ 132 | totalEmails += stat.Count 133 | } 134 | } 135 | 136 | fmt.Printf("\nSummary: %d/%d folders accessible, %d total emails\n", 137 | successfulFolders, len(stats), totalEmails) 138 | } 139 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "io" 5 | "mime" 6 | "strings" 7 | "testing" 8 | 9 | "golang.org/x/net/html/charset" 10 | ) 11 | 12 | func parseRecords(d *Dialer, records [][]*Token) (map[int]*Email, error) { 13 | emails := make(map[int]*Email, len(records)) 14 | CharsetReader := func(label string, input io.Reader) (io.Reader, error) { 15 | label = strings.Replace(label, "windows-", "cp", -1) 16 | encoding, _ := charset.Lookup(label) 17 | return encoding.NewDecoder().Reader(input), nil 18 | } 19 | dec := mime.WordDecoder{CharsetReader: CharsetReader} 20 | for _, tks := range records { 21 | e := &Email{} 22 | skip := 0 23 | for i, t := range tks { 24 | if skip > 0 { 25 | skip-- 26 | continue 27 | } 28 | if err := d.CheckType(t, []TType{TLiteral}, tks, "in root"); err != nil { 29 | return nil, err 30 | } 31 | switch t.Str { 32 | case "ENVELOPE": 33 | if err := d.CheckType(tks[i+1], []TType{TContainer}, tks, "after ENVELOPE"); err != nil { 34 | return nil, err 35 | } 36 | if err := d.CheckType(tks[i+1].Tokens[EDate], []TType{TQuoted, TNil}, tks, "for ENVELOPE[%d]", EDate); err != nil { 37 | return nil, err 38 | } 39 | if err := d.CheckType(tks[i+1].Tokens[ESubject], []TType{TQuoted, TAtom, TNil}, tks, "for ENVELOPE[%d]", ESubject); err != nil { 40 | return nil, err 41 | } 42 | e.Subject, _ = dec.DecodeHeader(tks[i+1].Tokens[ESubject].Str) 43 | for _, a := range []struct { 44 | dest *EmailAddresses 45 | pos uint8 46 | debug string 47 | }{ 48 | {&e.To, ETo, "TO"}, 49 | } { 50 | if tks[i+1].Tokens[a.pos].Type != TNil { 51 | if err := d.CheckType(tks[i+1].Tokens[a.pos], []TType{TNil, TContainer}, tks, "for ENVELOPE[%d]", a.pos); err != nil { 52 | return nil, err 53 | } 54 | *a.dest = make(map[string]string, len(tks[i+1].Tokens[a.pos].Tokens)) 55 | for j, t := range tks[i+1].Tokens[a.pos].Tokens { 56 | if err := d.CheckType(t.Tokens[EEName], []TType{TQuoted, TAtom, TNil}, tks, "for %s[%d][%d]", a.debug, j, EEName); err != nil { 57 | return nil, err 58 | } 59 | if err := d.CheckType(t.Tokens[EEMailbox], []TType{TQuoted, TAtom, TNil}, tks, "for %s[%d][%d]", a.debug, j, EEMailbox); err != nil { 60 | return nil, err 61 | } 62 | if err := d.CheckType(t.Tokens[EEHost], []TType{TQuoted, TAtom, TNil}, tks, "for %s[%d][%d]", a.debug, j, EEHost); err != nil { 63 | return nil, err 64 | } 65 | name, err := dec.DecodeHeader(t.Tokens[EEName].Str) 66 | if err != nil { 67 | return nil, err 68 | } 69 | mailbox, err := dec.DecodeHeader(t.Tokens[EEMailbox].Str) 70 | if err != nil { 71 | return nil, err 72 | } 73 | host, err := dec.DecodeHeader(t.Tokens[EEHost].Str) 74 | if err != nil { 75 | return nil, err 76 | } 77 | (*a.dest)[strings.ToLower(mailbox+"@"+host)] = name 78 | } 79 | } 80 | } 81 | skip++ 82 | case "UID": 83 | if err := d.CheckType(tks[i+1], []TType{TNumber}, tks, "after UID"); err != nil { 84 | return nil, err 85 | } 86 | e.UID = tks[i+1].Num 87 | skip++ 88 | } 89 | } 90 | emails[e.UID] = e 91 | } 92 | return emails, nil 93 | } 94 | 95 | func TestEnvelopeAtomAddress(t *testing.T) { 96 | name := "CBJ SAP SUPPORT INSIGHT" 97 | env := &Token{Type: TContainer, Tokens: []*Token{ 98 | {Type: TNil}, // date 99 | {Type: TAtom, Str: "sub"}, 100 | {Type: TNil}, 101 | {Type: TNil}, 102 | {Type: TNil}, 103 | {Type: TContainer, Tokens: []*Token{ 104 | {Type: TContainer, Tokens: []*Token{ 105 | {Type: TAtom, Str: name}, 106 | {Type: TNil}, 107 | {Type: TAtom, Str: "admin"}, 108 | {Type: TAtom, Str: "example.com"}, 109 | }}, 110 | }}, 111 | {Type: TNil}, 112 | {Type: TNil}, 113 | {Type: TNil}, 114 | {Type: TQuoted, Str: ""}, 115 | }} 116 | records := [][]*Token{{ 117 | {Type: TLiteral, Str: "UID"}, 118 | {Type: TNumber, Num: 1}, 119 | {Type: TLiteral, Str: "ENVELOPE"}, 120 | env, 121 | }} 122 | d := &Dialer{} 123 | emails, err := parseRecords(d, records) 124 | if err != nil { 125 | t.Fatalf("parseRecords error: %v", err) 126 | } 127 | addr, ok := emails[1].To["admin@example.com"] 128 | if !ok { 129 | t.Fatalf("address not parsed") 130 | } 131 | if addr != name { 132 | t.Fatalf("got %q want %q", addr, name) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /examples/fetch_emails/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | // Connect to server 13 | m, err := imap.New("username", "password", "mail.server.com", 993) 14 | if err != nil { 15 | log.Fatalf("Failed to connect: %v", err) 16 | } 17 | defer func() { 18 | if err := m.Close(); err != nil { 19 | log.Printf("Failed to close connection: %v", err) 20 | } 21 | }() 22 | 23 | err = m.SelectFolder("INBOX") 24 | if err != nil { 25 | log.Fatalf("Failed to select INBOX: %v", err) 26 | } 27 | 28 | uids, err := m.GetUIDs("1:5") // Get first 5 emails 29 | if err != nil { 30 | log.Fatalf("Failed to get UIDs: %v", err) 31 | } 32 | 33 | if len(uids) == 0 { 34 | fmt.Println("No emails found in INBOX") 35 | return 36 | } 37 | 38 | fmt.Printf("Found %d emails to fetch\n\n", len(uids)) 39 | 40 | fmt.Println("=== Fetching Overviews (Headers Only - FAST) ===") 41 | 42 | overviews, err := m.GetOverviews(uids...) 43 | if err != nil { 44 | log.Fatalf("Failed to get overviews: %v", err) 45 | } 46 | 47 | for uid, email := range overviews { 48 | fmt.Printf("UID %d:\n", uid) 49 | fmt.Printf(" Subject: %s\n", email.Subject) 50 | fmt.Printf(" From: %s\n", email.From) 51 | fmt.Printf(" Date: %s\n", email.Sent) 52 | fmt.Printf(" Size: %d bytes (%.1f KB)\n", email.Size, float64(email.Size)/1024) 53 | fmt.Printf(" Flags: %v\n", email.Flags) 54 | fmt.Println() 55 | } 56 | 57 | fmt.Println("=== Fetching Full Emails (With Bodies - SLOWER) ===") 58 | 59 | // Limit to first 3 for full fetch (to keep example fast) 60 | fetchUIDs := uids 61 | if len(uids) > 3 { 62 | fetchUIDs = uids[:3] 63 | } 64 | 65 | emails, err := m.GetEmails(fetchUIDs...) 66 | if err != nil { 67 | log.Fatalf("Failed to get emails: %v", err) 68 | } 69 | 70 | for uid, email := range emails { 71 | fmt.Printf("\n=== Email UID %d ===\n", uid) 72 | fmt.Printf("Subject: %s\n", email.Subject) 73 | fmt.Printf("From: %s\n", email.From) 74 | fmt.Printf("To: %s\n", email.To) 75 | fmt.Printf("CC: %s\n", email.CC) 76 | fmt.Printf("BCC: %s\n", email.BCC) 77 | fmt.Printf("Reply-To: %s\n", email.ReplyTo) 78 | fmt.Printf("Date Sent: %s\n", email.Sent) 79 | fmt.Printf("Date Received: %s\n", email.Received) 80 | fmt.Printf("Message-ID: %s\n", email.MessageID) 81 | fmt.Printf("Flags: %v\n", email.Flags) 82 | fmt.Printf("Size: %d bytes (%.1f KB)\n", email.Size, float64(email.Size)/1024) 83 | 84 | if len(email.Text) > 0 { 85 | preview := email.Text 86 | if len(preview) > 200 { 87 | preview = preview[:200] + "..." 88 | } 89 | // Clean up whitespace for display 90 | preview = strings.TrimSpace(preview) 91 | preview = strings.ReplaceAll(preview, "\n\n\n", "\n\n") 92 | fmt.Printf("\nText Preview:\n%s\n", preview) 93 | fmt.Printf("(Total text length: %d characters)\n", len(email.Text)) 94 | } 95 | 96 | if len(email.HTML) > 0 { 97 | fmt.Printf("\nHTML content present: %d bytes (%.1f KB)\n", 98 | len(email.HTML), float64(len(email.HTML))/1024) 99 | // Show first 100 chars of HTML 100 | htmlPreview := email.HTML 101 | if len(htmlPreview) > 100 { 102 | htmlPreview = htmlPreview[:100] + "..." 103 | } 104 | fmt.Printf("HTML Preview: %s\n", htmlPreview) 105 | } 106 | 107 | // Attachments 108 | if len(email.Attachments) > 0 { 109 | fmt.Printf("\nAttachments (%d):\n", len(email.Attachments)) 110 | totalSize := 0 111 | for i, att := range email.Attachments { 112 | fmt.Printf(" %d. %s\n", i+1, att.Name) 113 | fmt.Printf(" - MIME Type: %s\n", att.MimeType) 114 | fmt.Printf(" - Size: %d bytes (%.1f KB)\n", 115 | len(att.Content), float64(len(att.Content))/1024) 116 | totalSize += len(att.Content) 117 | } 118 | fmt.Printf(" Total attachments size: %.1f KB\n", float64(totalSize)/1024) 119 | } else { 120 | fmt.Println("\nNo attachments") 121 | } 122 | 123 | fmt.Println("\n" + strings.Repeat("-", 50)) 124 | } 125 | 126 | fmt.Println("\n=== Using the String() Method ===") 127 | 128 | // The String() method provides a quick summary 129 | for uid, email := range emails { 130 | fmt.Printf("UID %d summary:\n", uid) 131 | fmt.Print(email) 132 | fmt.Println() 133 | } 134 | 135 | // Example of processing attachments 136 | fmt.Println("\n=== Processing Attachments Example ===") 137 | 138 | for uid, email := range emails { 139 | if len(email.Attachments) > 0 { 140 | fmt.Printf("Email UID %d has %d attachment(s):\n", uid, len(email.Attachments)) 141 | for _, att := range email.Attachments { 142 | fmt.Printf(" - %s: ", att.Name) 143 | 144 | // You could save attachments to disk like this: 145 | // err := os.WriteFile(att.Name, att.Content, 0644) 146 | // if err != nil { 147 | // fmt.Printf("Failed to save: %v\n", err) 148 | // } else { 149 | // fmt.Printf("Saved to disk\n") 150 | // } 151 | 152 | // For demo, just show what we would do 153 | fmt.Printf("Would save %d bytes to disk\n", len(att.Content)) 154 | } 155 | break // Just show first email with attachments 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /examples/idle_monitoring/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | imap "github.com/BrianLeishman/go-imap" 12 | ) 13 | 14 | func main() { 15 | // Connect to server 16 | m, err := imap.New("username", "password", "mail.server.com", 993) 17 | if err != nil { 18 | log.Fatalf("Failed to connect: %v", err) 19 | } 20 | defer func() { 21 | if err := m.Close(); err != nil { 22 | log.Printf("Failed to close connection: %v", err) 23 | } 24 | }() 25 | 26 | // Select the folder to monitor 27 | if err := m.SelectFolder("INBOX"); err != nil { 28 | log.Fatalf("Failed to select INBOX: %v", err) 29 | } 30 | 31 | fmt.Println("=== IDLE Monitoring Example ===") 32 | fmt.Println("IDLE allows real-time notifications when mailbox changes occur") 33 | fmt.Println("The connection will automatically refresh IDLE every 5 minutes (RFC requirement)") 34 | fmt.Println() 35 | 36 | // Create an event handler 37 | handler := &imap.IdleHandler{ 38 | // New email arrived 39 | OnExists: func(e imap.ExistsEvent) { 40 | fmt.Printf("[EXISTS] New message arrived! Message index: %d\n", e.MessageIndex) 41 | fmt.Printf(" Timestamp: %s\n", time.Now().Format("15:04:05")) 42 | 43 | // You might want to fetch the new email 44 | // Note: MessageIndex is the sequence number, not UID 45 | fmt.Printf(" Fetching new email details...\n") 46 | 47 | // Convert sequence number to UID 48 | uids, err := m.GetUIDs(fmt.Sprintf("%d", e.MessageIndex)) 49 | if err == nil && len(uids) > 0 { 50 | uid := uids[0] 51 | overviews, err := m.GetOverviews(uid) 52 | if err == nil && len(overviews) > 0 { 53 | email := overviews[uid] 54 | fmt.Printf(" Subject: %s\n", email.Subject) 55 | fmt.Printf(" From: %s\n", email.From) 56 | fmt.Printf(" Size: %.1f KB\n", float64(email.Size)/1024) 57 | } 58 | } 59 | fmt.Println() 60 | }, 61 | 62 | // Email was deleted/expunged 63 | OnExpunge: func(e imap.ExpungeEvent) { 64 | fmt.Printf("[EXPUNGE] Message removed at index: %d\n", e.MessageIndex) 65 | fmt.Printf(" Timestamp: %s\n", time.Now().Format("15:04:05")) 66 | fmt.Println() 67 | }, 68 | 69 | // Email flags changed (read, flagged, etc.) 70 | OnFetch: func(e imap.FetchEvent) { 71 | fmt.Printf("[FETCH] Flags changed\n") 72 | fmt.Printf(" Message Index: %d\n", e.MessageIndex) 73 | fmt.Printf(" UID: %d\n", e.UID) 74 | fmt.Printf(" New Flags: %v\n", e.Flags) 75 | fmt.Printf(" Timestamp: %s\n", time.Now().Format("15:04:05")) 76 | 77 | // Interpret the flags 78 | flagDescriptions := []string{} 79 | for _, flag := range e.Flags { 80 | switch flag { 81 | case "\\Seen": 82 | flagDescriptions = append(flagDescriptions, "marked as read") 83 | case "\\Flagged": 84 | flagDescriptions = append(flagDescriptions, "starred/flagged") 85 | case "\\Answered": 86 | flagDescriptions = append(flagDescriptions, "marked as answered") 87 | case "\\Deleted": 88 | flagDescriptions = append(flagDescriptions, "marked for deletion") 89 | case "\\Draft": 90 | flagDescriptions = append(flagDescriptions, "marked as draft") 91 | } 92 | } 93 | if len(flagDescriptions) > 0 { 94 | fmt.Printf(" Interpretation: Email was %s\n", flagDescriptions) 95 | } 96 | fmt.Println() 97 | }, 98 | } 99 | 100 | // Get initial state 101 | allUIDs, _ := m.GetUIDs("ALL") 102 | unseenUIDs, _ := m.GetUIDs("UNSEEN") 103 | fmt.Printf("Initial state: %d total emails, %d unread\n", len(allUIDs), len(unseenUIDs)) 104 | fmt.Println() 105 | 106 | // Start IDLE (non-blocking, runs in background) 107 | fmt.Println("Starting IDLE monitoring...") 108 | fmt.Println("Press Ctrl+C to stop") 109 | fmt.Println("Try these actions in your email client to see events:") 110 | fmt.Println(" - Send yourself an email") 111 | fmt.Println(" - Mark an email as read/unread") 112 | fmt.Println(" - Star/flag an email") 113 | fmt.Println(" - Delete an email") 114 | fmt.Println() 115 | 116 | err = m.StartIdle(handler) 117 | if err != nil { 118 | log.Fatalf("Failed to start IDLE: %v", err) 119 | } 120 | 121 | // Set up signal handling for graceful shutdown 122 | sigChan := make(chan os.Signal, 1) 123 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 124 | 125 | // Also set up a timer for demo purposes (30 minutes max) 126 | timer := time.NewTimer(30 * time.Minute) 127 | 128 | // Wait for interrupt or timeout 129 | select { 130 | case <-sigChan: 131 | fmt.Println("\nInterrupt received, stopping IDLE...") 132 | case <-timer.C: 133 | fmt.Println("\n30 minute demo timeout reached, stopping IDLE...") 134 | } 135 | 136 | // Stop IDLE monitoring 137 | err = m.StopIdle() 138 | if err != nil { 139 | log.Printf("Error stopping IDLE: %v", err) 140 | } else { 141 | fmt.Println("IDLE monitoring stopped successfully") 142 | } 143 | 144 | // Get final state 145 | allUIDs, _ = m.GetUIDs("ALL") 146 | unseenUIDs, _ = m.GetUIDs("UNSEEN") 147 | fmt.Printf("\nFinal state: %d total emails, %d unread\n", len(allUIDs), len(unseenUIDs)) 148 | } 149 | -------------------------------------------------------------------------------- /examples/literal_search/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | fmt.Println("=== RFC 3501 Section 7.5 Literal Search Example ===") 13 | fmt.Println("This example demonstrates searching with non-ASCII characters using literal syntax") 14 | fmt.Println() 15 | 16 | // Connect to server 17 | m, err := imap.New("username", "password", "mail.server.com", 993) 18 | if err != nil { 19 | log.Fatalf("Failed to connect: %v", err) 20 | } 21 | defer func() { 22 | if err := m.Close(); err != nil { 23 | log.Printf("Failed to close connection: %v", err) 24 | } 25 | }() 26 | 27 | // Select folder first 28 | err = m.SelectFolder("INBOX") 29 | if err != nil { 30 | log.Fatalf("Failed to select INBOX: %v", err) 31 | } 32 | 33 | fmt.Println("Connected and selected INBOX successfully!") 34 | fmt.Println() 35 | 36 | // Example searches with literal syntax for various character sets 37 | // Using the new MakeIMAPLiteral helper function for convenience 38 | literalSearches := []struct { 39 | description string 40 | query string 41 | language string 42 | }{ 43 | { 44 | "Search for Cyrillic text 'тест' (test) in subject", 45 | `CHARSET UTF-8 Subject ` + imap.MakeIMAPLiteral("тест"), 46 | "Russian", 47 | }, 48 | { 49 | "Search for Chinese text '测试' (test) in subject", 50 | `CHARSET UTF-8 Subject ` + imap.MakeIMAPLiteral("测试"), 51 | "Chinese", 52 | }, 53 | { 54 | "Search for Japanese text 'テスト' (test) in subject", 55 | `CHARSET UTF-8 Subject ` + imap.MakeIMAPLiteral("テスト"), 56 | "Japanese", 57 | }, 58 | { 59 | "Search for Arabic text 'اختبار' (test) in subject", 60 | `CHARSET UTF-8 Subject ` + imap.MakeIMAPLiteral("اختبار"), 61 | "Arabic", 62 | }, 63 | { 64 | "Search for emoji '😀👍' in body text", 65 | `CHARSET UTF-8 BODY ` + imap.MakeIMAPLiteral("😀👍"), 66 | "Emoji", 67 | }, 68 | { 69 | "Search for German umlaut 'Prüfung' (test) in subject", 70 | `CHARSET UTF-8 Subject ` + imap.MakeIMAPLiteral("Prüfung"), 71 | "German", 72 | }, 73 | } 74 | 75 | fmt.Println("=== Non-ASCII Searches Using Literal Syntax ===") 76 | fmt.Println() 77 | 78 | for i, search := range literalSearches { 79 | fmt.Printf("%d. %s (%s)\n", i+1, search.description, search.language) 80 | fmt.Printf(" Query: CHARSET UTF-8 Subject/BODY {n}\\r\\n\n") 81 | fmt.Printf(" Actual bytes: %d\n", len([]byte(search.query[strings.LastIndex(search.query, "\n")+1:]))) 82 | 83 | // Perform the search 84 | uids, err := m.GetUIDs(search.query) 85 | if err != nil { 86 | fmt.Printf(" ❌ Search failed: %v\n", err) 87 | } else if len(uids) == 0 { 88 | fmt.Printf(" ℹ️ No emails found matching this criteria\n") 89 | } else { 90 | fmt.Printf(" ✅ Found %d email(s) with UIDs: %v\n", len(uids), uids[:min(len(uids), 5)]) 91 | if len(uids) > 5 { 92 | fmt.Printf(" ... and %d more\n", len(uids)-5) 93 | } 94 | } 95 | fmt.Println() 96 | } 97 | 98 | // Compare with regular ASCII search 99 | fmt.Println("=== Regular ASCII Search (for comparison) ===") 100 | asciiSearches := []string{ 101 | `SUBJECT "test"`, 102 | `BODY "hello"`, 103 | `FROM "example.com"`, 104 | } 105 | 106 | for _, search := range asciiSearches { 107 | fmt.Printf("Query: %s\n", search) 108 | uids, err := m.GetUIDs(search) 109 | if err != nil { 110 | fmt.Printf("❌ Search failed: %v\n", err) 111 | } else { 112 | fmt.Printf("✅ Found %d email(s)\n", len(uids)) 113 | } 114 | fmt.Println() 115 | } 116 | 117 | fmt.Println("=== Key Points About Literal Syntax ===") 118 | fmt.Println("• Use CHARSET UTF-8 for non-ASCII searches") 119 | fmt.Println("• The {n} syntax specifies exact byte count (not character count!)") 120 | fmt.Println("• UTF-8 characters may use 1-4 bytes per character") 121 | fmt.Println("• The library automatically detects {n} syntax and handles the continuation protocol") 122 | fmt.Println("• Backward compatibility: regular ASCII searches work unchanged") 123 | fmt.Println("• NEW: Use imap.MakeIMAPLiteral() helper for automatic byte counting") 124 | fmt.Println() 125 | 126 | fmt.Println("=== MakeIMAPLiteral Helper Function Examples ===") 127 | helperExamples := []string{"test", "тест", "测试", "😀👍"} 128 | for _, text := range helperExamples { 129 | literal := imap.MakeIMAPLiteral(text) 130 | fmt.Printf("imap.MakeIMAPLiteral(\"%s\") = \"%s\"\n", text, strings.ReplaceAll(literal, "\r\n", "\\r\\n")) 131 | } 132 | fmt.Println() 133 | 134 | fmt.Println("Example byte counts for different characters:") 135 | examples := map[string]string{ 136 | "test": "4 bytes (ASCII)", 137 | "тест": "8 bytes (Cyrillic)", 138 | "测试": "6 bytes (Chinese)", 139 | "テスト": "9 bytes (Japanese)", 140 | "اختبار": "12 bytes (Arabic)", 141 | "😀👍": "8 bytes (Emoji)", 142 | "Prüfung": "8 bytes (German with umlaut)", 143 | } 144 | 145 | for text, info := range examples { 146 | fmt.Printf("• '%s' = %s\n", text, info) 147 | } 148 | } 149 | 150 | // Helper function for Go versions without min builtin 151 | func min(a, b int) int { 152 | if a < b { 153 | return a 154 | } 155 | return b 156 | } 157 | -------------------------------------------------------------------------------- /examples/complete_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | imap.Verbose = false 13 | imap.RetryCount = 3 14 | imap.DialTimeout = 10 * time.Second 15 | imap.CommandTimeout = 30 * time.Second 16 | 17 | // Connect 18 | fmt.Println("Connecting to IMAP server...") 19 | // NOTE: Replace with your actual credentials and server 20 | m, err := imap.New("your-email@gmail.com", "your-password", "imap.gmail.com", 993) 21 | if err != nil { 22 | log.Fatalf("Connection failed: %v", err) 23 | } 24 | defer func() { 25 | if err := m.Close(); err != nil { 26 | log.Printf("Failed to close connection: %v", err) 27 | } 28 | }() 29 | 30 | fmt.Println("\n📁 Available folders:") 31 | folders, err := m.GetFolders() 32 | if err != nil { 33 | log.Fatalf("Failed to get folders: %v", err) 34 | } 35 | for _, folder := range folders { 36 | fmt.Printf(" - %s\n", folder) 37 | } 38 | 39 | fmt.Println("\n📥 Selecting INBOX...") 40 | if err := m.SelectFolder("INBOX"); err != nil { 41 | log.Fatalf("Failed to select INBOX: %v", err) 42 | } 43 | 44 | fmt.Println("\n🔍 Searching for unread emails...") 45 | unreadUIDs, err := m.GetUIDs("UNSEEN") 46 | if err != nil { 47 | log.Fatalf("Search failed: %v", err) 48 | } 49 | fmt.Printf("Found %d unread emails\n", len(unreadUIDs)) 50 | 51 | // Fetch first 5 unread (or less) 52 | limit := 5 53 | if len(unreadUIDs) < limit { 54 | limit = len(unreadUIDs) 55 | } 56 | 57 | if limit > 0 { 58 | fmt.Printf("\n📧 Fetching first %d unread emails...\n", limit) 59 | emails, err := m.GetEmails(unreadUIDs[:limit]...) 60 | if err != nil { 61 | log.Fatalf("Failed to fetch emails: %v", err) 62 | } 63 | 64 | for uid, email := range emails { 65 | fmt.Printf("\n--- Email UID %d ---\n", uid) 66 | fmt.Printf("From: %s\n", email.From) 67 | fmt.Printf("Subject: %s\n", email.Subject) 68 | fmt.Printf("Date: %s\n", email.Sent.Format("Jan 2, 2006 3:04 PM")) 69 | fmt.Printf("Size: %.1f KB\n", float64(email.Size)/1024) 70 | 71 | if len(email.Text) > 100 { 72 | fmt.Printf("Preview: %.100s...\n", email.Text) 73 | } else if len(email.Text) > 0 { 74 | fmt.Printf("Preview: %s\n", email.Text) 75 | } 76 | 77 | if len(email.Attachments) > 0 { 78 | fmt.Printf("Attachments: %d\n", len(email.Attachments)) 79 | for _, att := range email.Attachments { 80 | fmt.Printf(" - %s (%.1f KB)\n", att.Name, float64(len(att.Content))/1024) 81 | } 82 | } 83 | 84 | if uid == unreadUIDs[0] { 85 | fmt.Printf("\n✓ Marking email %d as read...\n", uid) 86 | if err := m.MarkSeen(uid); err != nil { 87 | fmt.Printf("Failed to mark as read: %v\n", err) 88 | } 89 | } 90 | } 91 | } 92 | 93 | fmt.Println("\n📊 Mailbox Statistics:") 94 | allUIDs, _ := m.GetUIDs("ALL") 95 | seenUIDs, _ := m.GetUIDs("SEEN") 96 | flaggedUIDs, _ := m.GetUIDs("FLAGGED") 97 | 98 | fmt.Printf(" Total emails: %d\n", len(allUIDs)) 99 | fmt.Printf(" Read emails: %d\n", len(seenUIDs)) 100 | fmt.Printf(" Unread emails: %d\n", len(allUIDs)-len(seenUIDs)) 101 | fmt.Printf(" Flagged emails: %d\n", len(flaggedUIDs)) 102 | 103 | fmt.Println("\n👀 Monitoring for new emails (10 seconds)...") 104 | handler := &imap.IdleHandler{ 105 | OnExists: func(e imap.ExistsEvent) { 106 | fmt.Printf(" 📬 New email arrived! (message #%d)\n", e.MessageIndex) 107 | }, 108 | } 109 | 110 | if err := m.StartIdle(handler); err == nil { 111 | time.Sleep(10 * time.Second) 112 | _ = m.StopIdle() 113 | } 114 | 115 | fmt.Println("\n✅ Done!") 116 | } 117 | 118 | /* Example Output: 119 | 120 | Connecting to IMAP server... 121 | 122 | 📁 Available folders: 123 | - INBOX 124 | - Sent 125 | - Drafts 126 | - Trash 127 | - [Gmail]/All Mail 128 | - [Gmail]/Spam 129 | - [Gmail]/Starred 130 | - [Gmail]/Important 131 | 132 | 📥 Selecting INBOX... 133 | 134 | 🔍 Searching for unread emails... 135 | Found 3 unread emails 136 | 137 | 📧 Fetching first 3 unread emails... 138 | 139 | --- Email UID 1247 --- 140 | From: notifications@github.com:GitHub 141 | Subject: [org/repo] New issue: Bug in authentication flow (#123) 142 | Date: Nov 11, 2024 2:15 PM 143 | Size: 8.5 KB 144 | Preview: User johndoe opened an issue: When trying to authenticate with OAuth2, the system returns a 401 error even with valid... 145 | Attachments: 0 146 | 147 | ✓ Marking email 1247 as read... 148 | 149 | --- Email UID 1248 --- 150 | From: team@company.com:Team Update 151 | Subject: Weekly Team Sync - Meeting Notes 152 | Date: Nov 11, 2024 3:30 PM 153 | Size: 12.3 KB 154 | Preview: Hi team, Here are the notes from today's sync: 1. Project Alpha is on track for Dec release 2. Need volunteers for... 155 | Attachments: 1 156 | - meeting-notes.pdf (156.2 KB) 157 | 158 | --- Email UID 1249 --- 159 | From: noreply@service.com:Service Alert 160 | Subject: Your monthly report is ready 161 | Date: Nov 11, 2024 4:45 PM 162 | Size: 45.6 KB 163 | Preview: Your monthly usage report for October 2024 is now available. View it in your dashboard or download the attached PDF... 164 | Attachments: 2 165 | - october-report.pdf (523.1 KB) 166 | - usage-chart.png (89.3 KB) 167 | 168 | 📊 Mailbox Statistics: 169 | Total emails: 1532 170 | Read emails: 1530 171 | Unread emails: 2 172 | Flagged emails: 23 173 | 174 | 👀 Monitoring for new emails (10 seconds)... 175 | 176 | ✅ Done! 177 | */ 178 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agent Guidelines for the IMAP Library 2 | 3 | > **Target toolchain:** Go 1.25.x (latest point-release: **1.25.1**). 4 | > Set `go 1.25` in `go.mod` and `toolchain go1.25.1`; older versions are **not** supported. ([go.dev][1]) 5 | 6 | --- 7 | 8 | ## 1 Design philosophy 9 | 10 | | Principle | Why it matters | 11 | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | 12 | | **Single-responsibility agents** | Easier testing, clearer contracts—no god-objects. | 13 | | **Context-first APIs** | Cancellation and deadlines propagate without extra params. | 14 | | **Fail fast, never ignore `error`** | Wrap or return every error with `%w`; no `//nolint:errcheck`. | 15 | | **No hidden globals** | Only `DefaultDialer`, documented and overridable. | 16 | | **Concurrency ≠ contention** | Prefer per-connection goroutines + channels; if shared state is unavoidable use `sync/atomic` or a small `Mutex`. | 17 | | **Use new language features when helpful** | Generic helpers, `errors.Join`, `maps.Clone`, etc.—but never “cleverness” for its own sake. | 18 | 19 | --- 20 | 21 | ## 4 Error handling & logging 22 | 23 | * Always wrap: `return fmt.Errorf("read greeting: %w", err)`. 24 | * Parallel tasks ➜ collect via `errgroup.Group`, then merge with `errors.Join`. 25 | * Use **one** `log/slog` instance per agent, enriched with `{component: "imap/agent"}`. 26 | 27 | --- 28 | 29 | ## 5 Concurrency contracts 30 | 31 | 1. Reader & writer goroutines share the TCP connection; demux by IMAP tag. 32 | 2. A cancelled `context.Context` **must** close the socket and shut down goroutines within 100 ms. 33 | 3. Public methods are concurrency-safe **only** when the doc comment says so. 34 | 35 | --- 36 | 37 | ## 6 Testing strategy 38 | 39 | | Layer | Approach | 40 | | ----------------------- | ----------------------------------------------------------------------------------------- | 41 | | Tokenizer / parser | Table-driven unit tests (≥ 90 % coverage). Include malformed literals & UTF-7 edge cases. | 42 | | Parser entry | `go test -fuzz` with seeds saved under `testdata/fuzz`. | 43 | | Races / goroutine leaks | CI runs `go test -race` and `go test -run=LeakCheck` with `go.uber.org/goleak`. | 44 | | Benchmarks | `go test -bench=.` on `parser` and `selector`; guard allocations. | 45 | 46 | --- 47 | 48 | ## 7 CI gates (`.github/workflows/ci.yml`) 49 | 50 | ```yaml 51 | go-version: '^1.25' 52 | steps: 53 | - run: go vet ./... 54 | - run: go test ./... -race -coverprofile=coverage.out 55 | - run: go test -fuzz=Fuzz -fuzztime=30s ./internal/parser 56 | - run: golangci-lint run --timeout 3m 57 | ``` 58 | 59 | Fail the pipeline on **any** linter warning. 60 | 61 | --- 62 | 63 | ## 8 Contribution checklist 64 | 65 | * [ ] Public symbols documented, with runnable `Example…` tests. 66 | * [ ] No unchecked errors (`errcheck ./...` is clean). 67 | * [ ] No duplication > 40 tokens (`dupl`). 68 | * [ ] Benchmarks regress ≤ 5 %. 69 | * [ ] `go test -race ./...` passes. 70 | 71 | --- 72 | 73 | ## 9 Deprecation & refactors 74 | 75 | Breaking API changes follow semver via `/vN` module paths. Internal refactors that improve clarity or adopt new 1.25 features may land at any time. 76 | 77 | --- 78 | 79 | ## 10 Workflow policy (PR‑only) 80 | 81 | - Never push directly to the default branch (`master`). 82 | - Always open a pull request from a topic branch (e.g., `fix/...`, `feat/...`, `chore/...`, `docs/...`). 83 | - Keep PRs scoped and focused; include a short rationale and a test plan when applicable. 84 | - CI must be green (vet, race tests, linters) before merge. 85 | - Prefer "Squash and merge" to keep history tidy; the squash title should be descriptive. 86 | - For trivial changes (docs, CI), still use a PR — no direct commits. 87 | 88 | ### Appendix A – Go 1.25 features worth using 89 | 90 | | Feature | Where we’ll use it | 91 | | -------------------------------------- | ------------------------------------------------------ | 92 | | **`go.mod ignore` directive** | Keep integration fixtures out of default module builds.| 93 | | **Container-aware `GOMAXPROCS`** | Match runtime defaults to container CPU quotas. | 94 | | **`testing/synctest`** | Stabilize concurrency-heavy tests without flakes. | 95 | | **`runtime/trace.FlightRecorder`** | Capture short traces when debugging agent stalls. | 96 | | **`go vet` hostport analyzer** | Catch IPv6-unsafe host/port string formatting. | 97 | 98 | See the Go 1.25 release notes for full details. ([tip.golang.org][2]) 99 | 100 | [1]: https://go.dev/doc/devel/release?utm_source=chatgpt.com "Release History - The Go Programming Language" 101 | [2]: https://tip.golang.org/doc/go1.25?utm_source=chatgpt.com "Go 1.25 Release Notes - The Go Programming Language" 102 | -------------------------------------------------------------------------------- /examples/search/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | // Connect to server 13 | m, err := imap.New("username", "password", "mail.server.com", 993) 14 | if err != nil { 15 | log.Fatalf("Failed to connect: %v", err) 16 | } 17 | defer func() { 18 | if err := m.Close(); err != nil { 19 | log.Printf("Failed to close connection: %v", err) 20 | } 21 | }() 22 | 23 | err = m.SelectFolder("INBOX") 24 | if err != nil { 25 | log.Fatalf("Failed to select INBOX: %v", err) 26 | } 27 | 28 | fmt.Println("=== Basic Searches ===") 29 | 30 | // Basic searches - returns slice of UIDs 31 | allUIDs, _ := m.GetUIDs("ALL") // All emails 32 | unseenUIDs, _ := m.GetUIDs("UNSEEN") // Unread emails 33 | recentUIDs, _ := m.GetUIDs("RECENT") // Recent emails 34 | seenUIDs, _ := m.GetUIDs("SEEN") // Read emails 35 | flaggedUIDs, _ := m.GetUIDs("FLAGGED") // Starred/flagged emails 36 | 37 | fmt.Printf("Found %d total emails\n", len(allUIDs)) 38 | fmt.Printf("Found %d unread emails\n", len(unseenUIDs)) 39 | fmt.Printf("Found %d recent emails\n", len(recentUIDs)) 40 | fmt.Printf("Found %d read emails\n", len(seenUIDs)) 41 | fmt.Printf("Found %d flagged emails\n", len(flaggedUIDs)) 42 | 43 | if len(unseenUIDs) > 0 && len(unseenUIDs) <= 10 { 44 | fmt.Printf("UIDs of unread: %v\n", unseenUIDs) 45 | } 46 | 47 | fmt.Println("\n=== Date-based Searches ===") 48 | 49 | // Date-based searches 50 | // Note: Use RFC 2822 date format 51 | today := time.Now().Format("2-Jan-2006") 52 | weekAgo := time.Now().AddDate(0, 0, -7).Format("2-Jan-2006") 53 | monthAgo := time.Now().AddDate(0, -1, 0).Format("2-Jan-2006") 54 | 55 | todayUIDs, _ := m.GetUIDs(fmt.Sprintf("ON %s", today)) 56 | sinceUIDs, _ := m.GetUIDs(fmt.Sprintf("SINCE %s", weekAgo)) 57 | beforeUIDs, _ := m.GetUIDs(fmt.Sprintf("BEFORE %s", today)) 58 | rangeUIDs, _ := m.GetUIDs(fmt.Sprintf("SINCE %s BEFORE %s", monthAgo, today)) 59 | 60 | fmt.Printf("Emails from today: %d\n", len(todayUIDs)) 61 | fmt.Printf("Emails since a week ago: %d\n", len(sinceUIDs)) 62 | fmt.Printf("Emails before today: %d\n", len(beforeUIDs)) 63 | fmt.Printf("Emails in the last month: %d\n", len(rangeUIDs)) 64 | 65 | fmt.Println("\n=== Sender/Recipient Searches ===") 66 | 67 | // From/To searches 68 | fromBossUIDs, _ := m.GetUIDs(`FROM "boss@company.com"`) 69 | toMeUIDs, _ := m.GetUIDs(`TO "me@company.com"`) 70 | ccUIDs, _ := m.GetUIDs(`CC "team@company.com"`) 71 | 72 | fmt.Printf("Emails from boss: %d\n", len(fromBossUIDs)) 73 | fmt.Printf("Emails to me: %d\n", len(toMeUIDs)) 74 | fmt.Printf("Emails CC'd to team: %d\n", len(ccUIDs)) 75 | 76 | fmt.Println("\n=== Content Searches ===") 77 | 78 | // Subject/body searches 79 | subjectUIDs, _ := m.GetUIDs(`SUBJECT "invoice"`) 80 | bodyUIDs, _ := m.GetUIDs(`BODY "payment"`) 81 | textUIDs, _ := m.GetUIDs(`TEXT "urgent"`) // Searches both subject and body 82 | 83 | fmt.Printf("Emails with 'invoice' in subject: %d\n", len(subjectUIDs)) 84 | fmt.Printf("Emails with 'payment' in body: %d\n", len(bodyUIDs)) 85 | fmt.Printf("Emails with 'urgent' anywhere: %d\n", len(textUIDs)) 86 | 87 | fmt.Println("\n=== Complex Searches ===") 88 | 89 | complexUIDs1, _ := m.GetUIDs(`UNSEEN FROM "support@github.com" SINCE 1-Jan-2024`) 90 | complexUIDs2, _ := m.GetUIDs(`FLAGGED SUBJECT "important" SINCE 1-Jan-2024`) 91 | complexUIDs3, _ := m.GetUIDs(`NOT SEEN NOT FROM "noreply@" SINCE 1-Jan-2024`) 92 | 93 | fmt.Printf("Unread emails from GitHub support this year: %d\n", len(complexUIDs1)) 94 | fmt.Printf("Flagged emails with 'important' in subject this year: %d\n", len(complexUIDs2)) 95 | fmt.Printf("Unread emails not from noreply addresses this year: %d\n", len(complexUIDs3)) 96 | 97 | fmt.Println("\n=== UID Ranges ===") 98 | 99 | firstUID, _ := m.GetUIDs("1") // First email 100 | lastUID, _ := m.GetUIDs("*") // Last email 101 | first10UIDs, _ := m.GetUIDs("1:10") // First 10 emails 102 | last10UIDs, _ := m.GetUIDs("*:10") // Last 10 emails (reverse) 103 | 104 | fmt.Printf("First email UID: %v\n", firstUID) 105 | fmt.Printf("Last email UID: %v\n", lastUID) 106 | fmt.Printf("First 10 email UIDs: %v\n", first10UIDs) 107 | if len(last10UIDs) <= 10 { 108 | fmt.Printf("Last 10 email UIDs: %v\n", last10UIDs) 109 | } else { 110 | fmt.Printf("Last 10 email UIDs: %d emails found\n", len(last10UIDs)) 111 | } 112 | 113 | fmt.Println("\n=== Size-based Searches ===") 114 | 115 | // Size-based searches (in bytes) 116 | largeUIDs, _ := m.GetUIDs("LARGER 10485760") // Emails larger than 10MB 117 | mediumUIDs, _ := m.GetUIDs("LARGER 1048576") // Emails larger than 1MB 118 | smallUIDs, _ := m.GetUIDs("SMALLER 10240") // Emails smaller than 10KB 119 | 120 | fmt.Printf("Emails larger than 10MB: %d\n", len(largeUIDs)) 121 | fmt.Printf("Emails larger than 1MB: %d\n", len(mediumUIDs)) 122 | fmt.Printf("Emails smaller than 10KB: %d\n", len(smallUIDs)) 123 | 124 | fmt.Println("\n=== Special Searches ===") 125 | 126 | answeredUIDs, _ := m.GetUIDs("ANSWERED") 127 | unansweredUIDs, _ := m.GetUIDs("UNANSWERED") 128 | deletedUIDs, _ := m.GetUIDs("DELETED") 129 | undeletedUIDs, _ := m.GetUIDs("UNDELETED") 130 | draftUIDs, _ := m.GetUIDs("DRAFT") 131 | undraftUIDs, _ := m.GetUIDs("UNDRAFT") 132 | 133 | fmt.Printf("Answered emails: %d\n", len(answeredUIDs)) 134 | fmt.Printf("Unanswered emails: %d\n", len(unansweredUIDs)) 135 | fmt.Printf("Deleted emails: %d\n", len(deletedUIDs)) 136 | fmt.Printf("Not deleted emails: %d\n", len(undeletedUIDs)) 137 | fmt.Printf("Draft emails: %d\n", len(draftUIDs)) 138 | fmt.Printf("Non-draft emails: %d\n", len(undraftUIDs)) 139 | } 140 | -------------------------------------------------------------------------------- /idle.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "time" 10 | "unicode" 11 | ) 12 | 13 | // Connection state constants 14 | const ( 15 | StateDisconnected = iota 16 | StateConnected 17 | StateSelected 18 | StateIdlePending 19 | StateIdling 20 | StateStoppingIdle 21 | ) 22 | 23 | // IDLE event type constants 24 | const ( 25 | IdleEventExists = "EXISTS" 26 | IdleEventExpunge = "EXPUNGE" 27 | IdleEventFetch = "FETCH" 28 | ) 29 | 30 | // ExistsEvent represents an EXISTS event from IDLE 31 | type ExistsEvent struct { 32 | MessageIndex int 33 | } 34 | 35 | // ExpungeEvent represents an EXPUNGE event from IDLE 36 | type ExpungeEvent struct { 37 | MessageIndex int 38 | } 39 | 40 | // FetchEvent represents a FETCH event from IDLE 41 | type FetchEvent struct { 42 | MessageIndex int 43 | UID uint32 44 | Flags []string 45 | } 46 | 47 | // IdleHandler provides callbacks for IDLE events 48 | type IdleHandler struct { 49 | OnExists func(event ExistsEvent) 50 | OnExpunge func(event ExpungeEvent) 51 | OnFetch func(event FetchEvent) 52 | } 53 | 54 | // runIdleEvent processes an IDLE event and calls the appropriate handler 55 | func (d *Dialer) runIdleEvent(data []byte, handler *IdleHandler) error { 56 | index := 0 57 | event := "" 58 | if _, err := fmt.Sscanf(string(data), "%d %s", &index, &event); err != nil { 59 | return fmt.Errorf("invalid IDLE event format: %s", data) 60 | } 61 | switch event { 62 | case IdleEventExists: 63 | if handler.OnExists != nil { 64 | go handler.OnExists(ExistsEvent{MessageIndex: index}) 65 | } 66 | case IdleEventExpunge: 67 | if handler.OnExpunge != nil { 68 | go handler.OnExpunge(ExpungeEvent{MessageIndex: index}) 69 | } 70 | case IdleEventFetch: 71 | if handler.OnFetch == nil { 72 | return nil 73 | } 74 | str := string(data) 75 | re := regexp.MustCompile(`(?i)^(\d+)\s+FETCH\s+\(([^)]*FLAGS\s*\(([^)]*)\)[^)]*)`) 76 | matches := re.FindStringSubmatch(str) 77 | if len(matches) == 4 { 78 | messageIndex, _ := strconv.Atoi(matches[1]) 79 | uid, _ := strconv.Atoi(matches[2]) 80 | flags := strings.FieldsFunc(strings.ReplaceAll(matches[3], `\`, ""), func(r rune) bool { 81 | return unicode.IsSpace(r) || r == ',' 82 | }) 83 | go handler.OnFetch(FetchEvent{MessageIndex: messageIndex, UID: uint32(uid), Flags: flags}) 84 | } else { 85 | return fmt.Errorf("invalid FETCH event format: %s", data) 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // StartIdle starts IDLE monitoring with automatic reconnection and timeout handling 93 | func (d *Dialer) StartIdle(handler *IdleHandler) error { 94 | go func() { 95 | ticker := time.NewTicker(5 * time.Minute) 96 | defer ticker.Stop() 97 | 98 | for { 99 | if !d.Connected { 100 | if err := d.Reconnect(); err != nil { 101 | if Verbose { 102 | warnLog(d.ConnNum, d.Folder, "IDLE reconnect failed", "error", err) 103 | } 104 | return 105 | } 106 | } 107 | if err := d.startIdleSingle(handler); err != nil { 108 | if Verbose { 109 | warnLog(d.ConnNum, d.Folder, "IDLE session stopped", "error", err) 110 | } 111 | return 112 | } 113 | 114 | select { 115 | case <-ticker.C: 116 | _ = d.StopIdle() 117 | case <-d.idleDone: 118 | return 119 | } 120 | } 121 | }() 122 | 123 | return nil 124 | } 125 | 126 | // startIdleSingle starts a single IDLE session 127 | func (d *Dialer) startIdleSingle(handler *IdleHandler) error { 128 | if d.State() == StateIdling || d.State() == StateIdlePending { 129 | return fmt.Errorf("already entering or in IDLE") 130 | } 131 | 132 | d.setState(StateIdlePending) 133 | 134 | d.idleStop = make(chan struct{}) 135 | d.idleDone = make(chan struct{}) 136 | idleReady := make(chan struct{}) 137 | 138 | go func() { 139 | defer func() { 140 | close(d.idleStop) 141 | if d.State() == StateIdling { 142 | d.setState(StateSelected) 143 | } 144 | }() 145 | 146 | _, err := d.Exec("IDLE", true, 0, func(line []byte) error { 147 | line = []byte(strings.ToUpper(string(line))) 148 | switch { 149 | case bytes.HasPrefix(line, []byte("+")): 150 | d.setState(StateIdling) 151 | close(idleReady) 152 | return nil 153 | case bytes.HasPrefix(line, []byte("* ")): 154 | strLine := string(line[2:]) 155 | if strings.HasPrefix(strLine, "OK") { 156 | return nil 157 | } 158 | if strings.HasPrefix(strLine, "BYE") { 159 | d.setState(StateDisconnected) 160 | _ = d.Close() 161 | return fmt.Errorf("server sent BYE: %s", line) 162 | } 163 | return d.runIdleEvent([]byte(strLine), handler) 164 | case bytes.HasPrefix(line, []byte("OK ")): 165 | strLine := string(line[3:]) 166 | if strings.HasPrefix(strLine, "IDLE") { 167 | d.setState(StateSelected) 168 | } 169 | return nil 170 | } 171 | return nil 172 | }) 173 | if err != nil { 174 | if Verbose { 175 | warnLog(d.ConnNum, d.Folder, "IDLE command error", "error", err) 176 | } 177 | d.setState(StateDisconnected) 178 | } 179 | }() 180 | 181 | select { 182 | case <-idleReady: 183 | return nil 184 | case <-time.After(5 * time.Second): 185 | d.setState(StateSelected) 186 | return fmt.Errorf("timeout waiting for + IDLE response") 187 | } 188 | } 189 | 190 | // StopIdle stops the current IDLE session 191 | func (d *Dialer) StopIdle() error { 192 | if d.State() != StateIdling { 193 | return fmt.Errorf("not in IDLE state") 194 | } 195 | 196 | if Verbose { 197 | debugLog(d.ConnNum, d.Folder, "sending DONE to exit IDLE") 198 | } 199 | if _, err := d.conn.Write([]byte("DONE\r\n")); err != nil { 200 | return fmt.Errorf("failed to send DONE: %v", err) 201 | } 202 | 203 | d.setState(StateStoppingIdle) 204 | close(d.idleDone) 205 | 206 | <-d.idleStop 207 | d.idleDone, d.idleStop = nil, nil 208 | if d.State() == StateStoppingIdle { 209 | d.setState(StateSelected) 210 | } 211 | 212 | return nil 213 | } 214 | 215 | // setState sets the connection state with proper locking 216 | func (d *Dialer) setState(s int) { 217 | d.stateMu.Lock() 218 | defer d.stateMu.Unlock() 219 | d.state = s 220 | } 221 | 222 | // State returns the current connection state with proper locking 223 | func (d *Dialer) State() int { 224 | d.stateMu.Lock() 225 | defer d.stateMu.Unlock() 226 | return d.state 227 | } 228 | -------------------------------------------------------------------------------- /examples/email_operations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | imap "github.com/BrianLeishman/go-imap" 8 | ) 9 | 10 | func main() { 11 | // Connect to server 12 | m, err := imap.New("username", "password", "mail.server.com", 993) 13 | if err != nil { 14 | log.Fatalf("Failed to connect: %v", err) 15 | } 16 | defer func() { 17 | if err := m.Close(); err != nil { 18 | log.Printf("Failed to close connection: %v", err) 19 | } 20 | }() 21 | 22 | err = m.SelectFolder("INBOX") 23 | if err != nil { 24 | log.Fatalf("Failed to select INBOX: %v", err) 25 | } 26 | 27 | uids, err := m.GetUIDs("1:3") // Get first 3 emails 28 | if err != nil { 29 | log.Fatalf("Failed to get UIDs: %v", err) 30 | } 31 | 32 | if len(uids) == 0 { 33 | fmt.Println("No emails found in INBOX") 34 | return 35 | } 36 | 37 | fmt.Printf("Working with %d email(s)\n\n", len(uids)) 38 | 39 | // Use first UID for demonstrations 40 | uid := uids[0] 41 | 42 | fmt.Println("=== Moving Emails ===") 43 | 44 | // First, let's check if Archive folder exists 45 | folders, err := m.GetFolders() 46 | if err != nil { 47 | log.Printf("Failed to get folders: %v", err) 48 | } 49 | 50 | archiveExists := false 51 | for _, folder := range folders { 52 | if folder == "INBOX/Archive" || folder == "Archive" { 53 | archiveExists = true 54 | break 55 | } 56 | } 57 | 58 | if archiveExists { 59 | // Move email to Archive 60 | err = m.MoveEmail(uid, "INBOX/Archive") 61 | if err != nil { 62 | log.Printf("Failed to move email: %v", err) 63 | } else { 64 | fmt.Printf("Moved email UID %d to Archive\n", uid) 65 | 66 | // Move it back for further demos 67 | if err := m.SelectFolder("INBOX/Archive"); err != nil { 68 | log.Printf("Failed to select Archive folder: %v", err) 69 | } else if err := m.MoveEmail(uid, "INBOX"); err != nil { 70 | log.Printf("Failed to move email back: %v", err) 71 | } else if err := m.SelectFolder("INBOX"); err != nil { 72 | log.Printf("Failed to select INBOX: %v", err) 73 | } 74 | fmt.Printf("Moved email back to INBOX for demo\n") 75 | } 76 | } else { 77 | fmt.Println("Archive folder doesn't exist, skipping move demo") 78 | } 79 | 80 | fmt.Println("\n=== Setting Individual Flags ===") 81 | 82 | err = m.MarkSeen(uid) 83 | if err != nil { 84 | log.Printf("Failed to mark as seen: %v", err) 85 | } else { 86 | fmt.Printf("Marked email UID %d as read (\\Seen flag set)\n", uid) 87 | } 88 | 89 | flags := imap.Flags{ 90 | Seen: imap.FlagRemove, 91 | } 92 | err = m.SetFlags(uid, flags) 93 | if err != nil { 94 | log.Printf("Failed to mark as unread: %v", err) 95 | } else { 96 | fmt.Printf("Marked email UID %d as unread (\\Seen flag removed)\n", uid) 97 | } 98 | 99 | fmt.Println("\n=== Setting Multiple Flags ===") 100 | 101 | // Set multiple flags at once 102 | flags = imap.Flags{ 103 | Seen: imap.FlagAdd, // Mark as read 104 | Flagged: imap.FlagAdd, // Star/flag the email 105 | Answered: imap.FlagRemove, // Remove answered flag 106 | } 107 | err = m.SetFlags(uid, flags) 108 | if err != nil { 109 | log.Printf("Failed to set flags: %v", err) 110 | } else { 111 | fmt.Printf("Set multiple flags on UID %d:\n", uid) 112 | fmt.Println(" - Added \\Seen (marked as read)") 113 | fmt.Println(" - Added \\Flagged (starred)") 114 | fmt.Println(" - Removed \\Answered") 115 | } 116 | 117 | // Check the flags 118 | overviews, err := m.GetOverviews(uid) 119 | if err == nil && len(overviews) > 0 { 120 | fmt.Printf("Current flags on UID %d: %v\n", uid, overviews[uid].Flags) 121 | } 122 | 123 | fmt.Println("\n=== Custom Keywords ===") 124 | 125 | // Custom keywords (if server supports - Gmail supports labels as keywords) 126 | flags = imap.Flags{ 127 | Keywords: map[string]bool{ 128 | "$Important": true, // Add custom keyword 129 | "$Processed": true, // Add another 130 | "$Pending": false, // Remove this keyword 131 | "$FollowUp": true, // Add this 132 | }, 133 | } 134 | err = m.SetFlags(uid, flags) 135 | if err != nil { 136 | log.Printf("Failed to set custom keywords: %v", err) 137 | fmt.Println("(Note: Not all servers support custom keywords)") 138 | } else { 139 | fmt.Printf("Set custom keywords on UID %d:\n", uid) 140 | fmt.Println(" - Added: $Important, $Processed, $FollowUp") 141 | fmt.Println(" - Removed: $Pending") 142 | } 143 | 144 | fmt.Println("\n=== Batch Flag Operations ===") 145 | 146 | if len(uids) > 1 { 147 | // Mark multiple emails as read 148 | for _, batchUID := range uids[:2] { 149 | err = m.MarkSeen(batchUID) 150 | if err != nil { 151 | log.Printf("Failed to mark UID %d as seen: %v", batchUID, err) 152 | } else { 153 | fmt.Printf("Marked UID %d as read\n", batchUID) 154 | } 155 | } 156 | 157 | // Flag/star multiple emails 158 | for _, batchUID := range uids[:2] { 159 | flags := imap.Flags{ 160 | Flagged: imap.FlagAdd, 161 | } 162 | err = m.SetFlags(batchUID, flags) 163 | if err != nil { 164 | log.Printf("Failed to flag UID %d: %v", batchUID, err) 165 | } else { 166 | fmt.Printf("Flagged/starred UID %d\n", batchUID) 167 | } 168 | } 169 | } 170 | 171 | fmt.Println("\n=== Deleting Emails ===") 172 | 173 | // WARNING: This will actually delete emails! 174 | // Uncomment only if you want to test deletion 175 | 176 | /* 177 | // Get an email to delete (maybe from Trash or a test folder) 178 | err = m.SelectFolder("Trash") 179 | if err == nil { 180 | trashUIDs, _ := m.GetUIDs("1") 181 | if len(trashUIDs) > 0 { 182 | deleteUID := trashUIDs[0] 183 | 184 | // Step 1: Mark as deleted (sets \Deleted flag) 185 | err = m.DeleteEmail(deleteUID) 186 | if err != nil { 187 | log.Printf("Failed to mark for deletion: %v", err) 188 | } else { 189 | fmt.Printf("Marked email UID %d for deletion (\\Deleted flag set)\n", deleteUID) 190 | } 191 | 192 | // Step 2: Expunge to permanently remove all \Deleted emails 193 | err = m.Expunge() 194 | if err != nil { 195 | log.Printf("Failed to expunge: %v", err) 196 | } else { 197 | fmt.Println("Permanently deleted all marked emails (expunged)") 198 | } 199 | } else { 200 | fmt.Println("No emails in Trash to delete") 201 | } 202 | 203 | // Go back to INBOX 204 | m.SelectFolder("INBOX") 205 | } else { 206 | fmt.Println("Trash folder not found, skipping deletion demo") 207 | } 208 | */ 209 | 210 | fmt.Println("\nNote: Delete operations are commented out to prevent accidental deletion") 211 | fmt.Println("Uncomment the deletion section if you want to test it") 212 | 213 | fmt.Println("\n=== Flag Reference ===") 214 | fmt.Println("Standard IMAP flags:") 215 | fmt.Println(" \\Seen - Message has been read") 216 | fmt.Println(" \\Answered - Message has been answered") 217 | fmt.Println(" \\Flagged - Message is flagged/starred") 218 | fmt.Println(" \\Deleted - Message is marked for deletion") 219 | fmt.Println(" \\Draft - Message is a draft") 220 | fmt.Println(" \\Recent - Message is recent (set by server)") 221 | fmt.Println("\nCustom keywords start with $ and are server-dependent") 222 | } 223 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "sync" 9 | 10 | retry "github.com/StirlingMarketingGroup/go-retry" 11 | ) 12 | 13 | var ( 14 | nextConnNum = 0 15 | nextConnNumMutex = sync.RWMutex{} 16 | ) 17 | 18 | // Dialer represents an IMAP connection 19 | type Dialer struct { 20 | conn *tls.Conn 21 | Folder string 22 | ReadOnly bool 23 | Username string 24 | Password string 25 | Host string 26 | Port int 27 | Connected bool 28 | ConnNum int 29 | state int 30 | stateMu sync.Mutex 31 | idleStop chan struct{} 32 | idleDone chan struct{} 33 | // useXOAUTH2 indicates whether XOAUTH2 authentication should be used 34 | // on (re)connection instead of LOGIN. It is set by NewWithOAuth2. 35 | useXOAUTH2 bool 36 | } 37 | 38 | // dialHost establishes a TLS connection to the IMAP server 39 | func dialHost(host string, port int) (*tls.Conn, error) { 40 | dialer := &net.Dialer{Timeout: DialTimeout} 41 | var cfg *tls.Config 42 | if TLSSkipVerify { 43 | cfg = &tls.Config{InsecureSkipVerify: true} 44 | } 45 | return tls.DialWithDialer(dialer, "tcp", host+":"+strconv.Itoa(port), cfg) 46 | } 47 | 48 | // NewWithOAuth2 creates a new IMAP connection using OAuth2 authentication 49 | func NewWithOAuth2(username string, accessToken string, host string, port int) (d *Dialer, err error) { 50 | nextConnNumMutex.RLock() 51 | connNum := nextConnNum 52 | nextConnNumMutex.RUnlock() 53 | 54 | nextConnNumMutex.Lock() 55 | nextConnNum++ 56 | nextConnNumMutex.Unlock() 57 | 58 | // Retry only the connection establishment, not authentication 59 | err = retry.Retry(func() error { 60 | if Verbose { 61 | debugLog(connNum, "", "establishing connection", "host", host, "port", port, "auth", "xoauth2") 62 | } 63 | var conn *tls.Conn 64 | conn, err = dialHost(host, port) 65 | if err != nil { 66 | if Verbose { 67 | debugLog(connNum, "", "connection attempt failed", "error", err) 68 | } 69 | return err 70 | } 71 | d = &Dialer{ 72 | conn: conn, 73 | Username: username, 74 | Password: accessToken, 75 | Host: host, 76 | Port: port, 77 | Connected: true, 78 | ConnNum: connNum, 79 | useXOAUTH2: true, 80 | } 81 | return nil 82 | }, RetryCount, func(err error) error { 83 | if Verbose { 84 | debugLog(connNum, "", "connection retry scheduled") 85 | if d != nil && d.conn != nil { 86 | _ = d.conn.Close() 87 | } 88 | } 89 | return nil 90 | }, func() error { 91 | if Verbose { 92 | debugLog(connNum, "", "retrying connection") 93 | } 94 | return nil 95 | }) 96 | if err != nil { 97 | warnLog(connNum, "", "failed to establish connection", "error", err) 98 | if d != nil && d.conn != nil { 99 | _ = d.conn.Close() 100 | } 101 | return nil, err 102 | } 103 | 104 | // Authenticate after connection is established - no retry for auth failures 105 | err = d.Authenticate(username, accessToken) 106 | if err != nil { 107 | errorLog(connNum, "", "authentication failed", "error", err) 108 | _ = d.Close() 109 | return nil, err 110 | } 111 | 112 | return d, nil 113 | } 114 | 115 | // New creates a new IMAP connection using username/password authentication 116 | func New(username string, password string, host string, port int) (d *Dialer, err error) { 117 | nextConnNumMutex.RLock() 118 | connNum := nextConnNum 119 | nextConnNumMutex.RUnlock() 120 | 121 | nextConnNumMutex.Lock() 122 | nextConnNum++ 123 | nextConnNumMutex.Unlock() 124 | 125 | // Retry only the connection establishment, not authentication 126 | err = retry.Retry(func() error { 127 | if Verbose { 128 | debugLog(connNum, "", "establishing connection", "host", host, "port", port, "auth", "login") 129 | } 130 | var conn *tls.Conn 131 | conn, err = dialHost(host, port) 132 | if err != nil { 133 | if Verbose { 134 | debugLog(connNum, "", "connection attempt failed", "error", err) 135 | } 136 | return err 137 | } 138 | d = &Dialer{ 139 | conn: conn, 140 | Username: username, 141 | Password: password, 142 | Host: host, 143 | Port: port, 144 | Connected: true, 145 | ConnNum: connNum, 146 | useXOAUTH2: false, 147 | } 148 | return nil 149 | }, RetryCount, func(err error) error { 150 | if Verbose { 151 | debugLog(connNum, "", "connection retry scheduled") 152 | if d != nil && d.conn != nil { 153 | _ = d.conn.Close() 154 | } 155 | } 156 | return nil 157 | }, func() error { 158 | if Verbose { 159 | debugLog(connNum, "", "retrying connection") 160 | } 161 | return nil 162 | }) 163 | if err != nil { 164 | warnLog(connNum, "", "failed to establish connection", "error", err) 165 | if d != nil && d.conn != nil { 166 | _ = d.conn.Close() 167 | } 168 | return nil, err 169 | } 170 | 171 | // Authenticate after connection is established - no retry for auth failures 172 | err = d.Login(username, password) 173 | if err != nil { 174 | errorLog(connNum, "", "authentication failed", "error", err) 175 | _ = d.Close() 176 | return nil, err 177 | } 178 | 179 | return d, nil 180 | } 181 | 182 | // Clone creates a copy of the dialer with the same configuration 183 | func (d *Dialer) Clone() (d2 *Dialer, err error) { 184 | if d.useXOAUTH2 { 185 | d2, err = NewWithOAuth2(d.Username, d.Password, d.Host, d.Port) 186 | } else { 187 | d2, err = New(d.Username, d.Password, d.Host, d.Port) 188 | } 189 | // d2.Verbose = d1.Verbose 190 | if d.Folder != "" { 191 | if d.ReadOnly { 192 | err = d2.ExamineFolder(d.Folder) 193 | } else { 194 | err = d2.SelectFolder(d.Folder) 195 | } 196 | if err != nil { 197 | return nil, fmt.Errorf("imap clone: %s", err) 198 | } 199 | } 200 | return d2, err 201 | } 202 | 203 | // Close closes the IMAP connection 204 | func (d *Dialer) Close() (err error) { 205 | if d.Connected { 206 | if Verbose { 207 | debugLog(d.ConnNum, d.Folder, "closing connection") 208 | } 209 | err = d.conn.Close() 210 | if err != nil { 211 | return fmt.Errorf("imap close: %s", err) 212 | } 213 | d.Connected = false 214 | } 215 | return err 216 | } 217 | 218 | // Reconnect closes and reopens the IMAP connection with re-authentication 219 | func (d *Dialer) Reconnect() (err error) { 220 | _ = d.Close() 221 | if Verbose { 222 | debugLog(d.ConnNum, d.Folder, "reopening connection") 223 | } 224 | 225 | conn, err := dialHost(d.Host, d.Port) 226 | if err != nil { 227 | return fmt.Errorf("imap reconnect dial: %s", err) 228 | } 229 | d.conn = conn 230 | d.Connected = true 231 | 232 | // Re-authenticate using the original method 233 | if d.useXOAUTH2 { 234 | if err := d.Authenticate(d.Username, d.Password); err != nil { 235 | // Best effort cleanup on failure 236 | _ = d.conn.Close() 237 | d.Connected = false 238 | return fmt.Errorf("imap reconnect auth xoauth2: %s", err) 239 | } 240 | } else { 241 | if err := d.Login(d.Username, d.Password); err != nil { 242 | _ = d.conn.Close() 243 | d.Connected = false 244 | return fmt.Errorf("imap reconnect login: %s", err) 245 | } 246 | } 247 | 248 | // Restore selected folder state if any 249 | if d.Folder != "" { 250 | if d.ReadOnly { 251 | if err := d.ExamineFolder(d.Folder); err != nil { 252 | return fmt.Errorf("imap reconnect examine: %s", err) 253 | } 254 | } else { 255 | if err := d.SelectFolder(d.Folder); err != nil { 256 | return fmt.Errorf("imap reconnect select: %s", err) 257 | } 258 | } 259 | } 260 | 261 | return nil 262 | } 263 | -------------------------------------------------------------------------------- /examples/error_handling/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | imap "github.com/BrianLeishman/go-imap" 9 | ) 10 | 11 | func main() { 12 | fmt.Println("=== Error Handling and Reconnection Example ===") 13 | 14 | // Configure retry and timeout behavior 15 | configureLibrarySettings() 16 | 17 | // Example 1: Handle connection errors 18 | handleConnectionErrors() 19 | 20 | // Example 2: Robust email fetching with automatic retry 21 | robustEmailFetch() 22 | 23 | // Example 3: Manual reconnection 24 | manualReconnection() 25 | 26 | // Example 4: Timeout configuration 27 | timeoutConfiguration() 28 | } 29 | 30 | func configureLibrarySettings() { 31 | fmt.Println("1. Configuring Library Settings") 32 | fmt.Println("--------------------------------") 33 | 34 | // Set retry configuration 35 | imap.RetryCount = 5 // Will retry failed operations 5 times 36 | fmt.Printf("RetryCount set to: %d\n", imap.RetryCount) 37 | 38 | // Enable verbose mode to see what's happening 39 | imap.Verbose = false // Set to true to see all IMAP commands/responses 40 | fmt.Printf("Verbose mode: %v\n", imap.Verbose) 41 | 42 | // Configure timeouts 43 | imap.DialTimeout = 10 * time.Second // Connection timeout 44 | imap.CommandTimeout = 30 * time.Second // Individual command timeout 45 | fmt.Printf("DialTimeout: %v\n", imap.DialTimeout) 46 | fmt.Printf("CommandTimeout: %v\n", imap.CommandTimeout) 47 | 48 | fmt.Println() 49 | } 50 | 51 | func handleConnectionErrors() { 52 | fmt.Println("2. Handling Connection Errors") 53 | fmt.Println("------------------------------") 54 | 55 | // Try to connect with invalid credentials (will fail) 56 | m, err := imap.New("invalid-user", "invalid-pass", "imap.gmail.com", 993) 57 | if err != nil { 58 | fmt.Printf("Expected error occurred: %v\n", err) 59 | fmt.Println("This is how you handle initial connection failures") 60 | } else { 61 | defer func() { 62 | if err := m.Close(); err != nil { 63 | log.Printf("Failed to close connection: %v", err) 64 | } 65 | }() 66 | fmt.Println("Unexpected: connection succeeded with invalid credentials") 67 | } 68 | 69 | // Try to connect to non-existent server 70 | m, err = imap.New("user", "pass", "non-existent-server.invalid", 993) 71 | if err != nil { 72 | fmt.Printf("Expected error for invalid server: %v\n", err) 73 | } else { 74 | defer func() { 75 | if err := m.Close(); err != nil { 76 | log.Printf("Failed to close connection: %v", err) 77 | } 78 | }() 79 | } 80 | 81 | fmt.Println() 82 | } 83 | 84 | func robustEmailFetch() { 85 | fmt.Println("3. Robust Email Fetching") 86 | fmt.Println("-------------------------") 87 | 88 | // NOTE: Replace with your actual credentials and server 89 | m, err := imap.New("username", "password", "mail.server.com", 993) 90 | if err != nil { 91 | fmt.Printf("Failed to connect: %v\n", err) 92 | fmt.Println("Skipping robust fetch example (need valid credentials)") 93 | fmt.Println() 94 | return 95 | } 96 | defer func() { 97 | if err := m.Close(); err != nil { 98 | log.Printf("Failed to close connection: %v", err) 99 | } 100 | }() 101 | 102 | // The library automatically handles reconnection for most operations 103 | fmt.Println("Connected successfully!") 104 | fmt.Println("The library will automatically:") 105 | fmt.Println(" 1. Detect connection failures") 106 | fmt.Println(" 2. Close the broken connection") 107 | fmt.Println(" 3. Create a new connection") 108 | fmt.Println(" 4. Re-authenticate (LOGIN or XOAUTH2)") 109 | fmt.Println(" 5. Re-select the previously selected folder") 110 | fmt.Println(" 6. Retry the failed command") 111 | fmt.Println() 112 | 113 | // Select folder with automatic retry 114 | err = m.SelectFolder("INBOX") 115 | if err != nil { 116 | // This only fails after all retries are exhausted 117 | fmt.Printf("Failed to select folder after %d retries: %v\n", imap.RetryCount, err) 118 | return 119 | } 120 | fmt.Println("Selected INBOX successfully") 121 | 122 | // Fetch emails with automatic retry on network issues 123 | uids, err := m.GetUIDs("1:5") 124 | if err != nil { 125 | fmt.Printf("Search failed after retries: %v\n", err) 126 | return 127 | } 128 | fmt.Printf("Found %d UIDs with automatic retry support\n", len(uids)) 129 | 130 | if len(uids) > 0 { 131 | // Fetch emails - will automatically retry on failure 132 | emails, err := m.GetEmails(uids[0]) 133 | if err != nil { 134 | fmt.Printf("Fetch failed after retries: %v\n", err) 135 | } else { 136 | fmt.Printf("Successfully fetched email with UID %d\n", uids[0]) 137 | for uid, email := range emails { 138 | fmt.Printf(" Subject: %s\n", email.Subject) 139 | fmt.Printf(" From: %s\n", email.From) 140 | _ = uid 141 | } 142 | } 143 | } 144 | 145 | fmt.Println() 146 | } 147 | 148 | func manualReconnection() { 149 | fmt.Println("4. Manual Reconnection") 150 | fmt.Println("-----------------------") 151 | 152 | // NOTE: Replace with your actual credentials and server 153 | m, err := imap.New("username", "password", "mail.server.com", 993) 154 | if err != nil { 155 | fmt.Printf("Failed to connect: %v\n", err) 156 | fmt.Println("Skipping manual reconnection example (need valid credentials)") 157 | fmt.Println() 158 | return 159 | } 160 | defer func() { 161 | if err := m.Close(); err != nil { 162 | log.Printf("Failed to close connection: %v", err) 163 | } 164 | }() 165 | 166 | fmt.Println("Connected successfully!") 167 | 168 | // Select a folder 169 | err = m.SelectFolder("INBOX") 170 | if err != nil { 171 | fmt.Printf("Failed to select folder: %v\n", err) 172 | 173 | // You can manually trigger a reconnection 174 | fmt.Println("Attempting manual reconnection...") 175 | if err := m.Reconnect(); err != nil { 176 | fmt.Printf("Manual reconnection failed: %v\n", err) 177 | return 178 | } 179 | fmt.Println("Reconnected successfully!") 180 | 181 | // Try selecting folder again 182 | if err := m.SelectFolder("INBOX"); err != nil { 183 | fmt.Printf("Failed to select folder after reconnect: %v\n", err) 184 | return 185 | } 186 | fmt.Println("Selected INBOX after reconnection") 187 | } 188 | 189 | fmt.Println() 190 | } 191 | 192 | func timeoutConfiguration() { 193 | fmt.Println("5. Timeout Configuration") 194 | fmt.Println("-------------------------") 195 | 196 | // Configure aggressive timeouts for demonstration 197 | originalDialTimeout := imap.DialTimeout 198 | originalCommandTimeout := imap.CommandTimeout 199 | 200 | imap.DialTimeout = 2 * time.Second // Very short connection timeout 201 | imap.CommandTimeout = 5 * time.Second // Short command timeout 202 | 203 | fmt.Printf("Using aggressive timeouts:\n") 204 | fmt.Printf(" DialTimeout: %v\n", imap.DialTimeout) 205 | fmt.Printf(" CommandTimeout: %v\n", imap.CommandTimeout) 206 | 207 | // Try to connect with short timeout 208 | start := time.Now() 209 | m, err := imap.New("user", "pass", "slow-server.example.com", 993) 210 | elapsed := time.Since(start) 211 | 212 | if err != nil { 213 | fmt.Printf("Connection failed after %v: %v\n", elapsed, err) 214 | fmt.Println("This demonstrates how DialTimeout works") 215 | } else { 216 | defer func() { 217 | if err := m.Close(); err != nil { 218 | log.Printf("Failed to close connection: %v", err) 219 | } 220 | }() 221 | 222 | // This search will timeout after CommandTimeout 223 | fmt.Println("Attempting a command that might timeout...") 224 | start = time.Now() 225 | _, err := m.GetUIDs("ALL") 226 | elapsed = time.Since(start) 227 | 228 | if err != nil { 229 | fmt.Printf("Command failed after %v: %v\n", elapsed, err) 230 | } else { 231 | fmt.Println("Command completed successfully") 232 | } 233 | } 234 | 235 | // Restore original timeouts 236 | imap.DialTimeout = originalDialTimeout 237 | imap.CommandTimeout = originalCommandTimeout 238 | 239 | fmt.Println() 240 | fmt.Println("=== Best Practices for Error Handling ===") 241 | fmt.Println("1. Set appropriate RetryCount based on your needs") 242 | fmt.Println("2. Use reasonable timeouts (10-30 seconds typically)") 243 | fmt.Println("3. Always check errors from operations") 244 | fmt.Println("4. Consider manual reconnection for critical operations") 245 | fmt.Println("5. Enable Verbose mode when debugging issues") 246 | fmt.Println("6. Log errors for monitoring and debugging") 247 | fmt.Println("7. Implement exponential backoff for custom retry logic") 248 | } 249 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestParseFetchTokensLiteralBoundary(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | wantErr bool 14 | errContains string 15 | description string 16 | wantTokens int // Expected number of tokens 17 | checkContent bool // Whether to check token content 18 | }{ 19 | { 20 | name: "empty literal {0}", 21 | input: "(BODY {0}\r\n)", 22 | wantErr: false, 23 | description: "Should handle empty literal {0} correctly", 24 | wantTokens: 2, // BODY and empty atom 25 | checkContent: true, 26 | }, 27 | { 28 | name: "literal with exact size", 29 | input: "(BODY {5}\r\nHello)", 30 | wantErr: false, 31 | description: "Should handle literal with exact matching size", 32 | wantTokens: 2, // BODY and "Hello" 33 | checkContent: true, 34 | }, 35 | { 36 | name: "literal size exceeds buffer - should take available data", 37 | input: "(BODY {10}\r\nHello )", 38 | wantErr: false, 39 | description: "Should handle literal where declared size exceeds available data", 40 | wantTokens: 2, // BODY and truncated content 41 | }, 42 | { 43 | name: "literal at end with size but no data", 44 | input: "(BODY {5}\r\n", 45 | wantErr: true, 46 | errContains: "literal size 5 but tokenStart", 47 | description: "Should error when literal declares size but has no data", 48 | }, 49 | { 50 | name: "literal with multiline content", 51 | input: "(BODY {15}\r\nThis is a test.)", 52 | wantErr: false, 53 | description: "Should handle literal with exact size match", 54 | wantTokens: 2, 55 | checkContent: true, 56 | }, 57 | { 58 | name: "multiple tokens with literal", 59 | input: "(UID 7 BODY {5}\r\nHello FLAGS (\\Seen))", 60 | wantErr: false, 61 | description: "Should handle complex input with literal in middle", 62 | wantTokens: 6, // UID, 7, BODY, "Hello", FLAGS, container 63 | }, 64 | { 65 | name: "literal with exact boundary", 66 | input: "(BODY {3}\r\nabc)", 67 | wantErr: false, 68 | description: "Should handle literal ending exactly at declared size", 69 | wantTokens: 2, 70 | }, 71 | } 72 | 73 | for _, tt := range tests { 74 | t.Run(tt.name, func(t *testing.T) { 75 | tokens, err := parseFetchTokens(tt.input) 76 | 77 | if tt.wantErr { 78 | if err == nil { 79 | t.Errorf("parseFetchTokens() error = nil, wantErr %v", tt.wantErr) 80 | return 81 | } 82 | if tt.errContains != "" && !contains(err.Error(), tt.errContains) { 83 | t.Errorf("parseFetchTokens() error = %v, want error containing %v", err, tt.errContains) 84 | } 85 | } else { 86 | if err != nil { 87 | t.Errorf("parseFetchTokens() unexpected error = %v for case: %s", err, tt.description) 88 | return 89 | } 90 | 91 | if tt.wantTokens > 0 && len(tokens) != tt.wantTokens { 92 | t.Errorf("parseFetchTokens() got %d tokens, want %d for case: %s", len(tokens), tt.wantTokens, tt.description) 93 | } 94 | 95 | if tt.checkContent && len(tokens) >= 2 { 96 | if tokens[0].Type != TLiteral || tokens[0].Str != "BODY" { 97 | t.Errorf("parseFetchTokens() first token = %+v, want BODY literal", tokens[0]) 98 | } 99 | if tt.name == "empty literal {0}" && tokens[1].Type != TAtom { 100 | t.Errorf("parseFetchTokens() second token type = %v, want TAtom for empty literal", tokens[1].Type) 101 | } 102 | if tt.name == "literal with exact size" && (tokens[1].Type != TAtom || tokens[1].Str != "Hello") { 103 | t.Errorf("parseFetchTokens() second token = %+v, want Hello atom", tokens[1]) 104 | } 105 | } 106 | } 107 | }) 108 | } 109 | } 110 | 111 | func TestCalculateTokenEnd(t *testing.T) { 112 | tests := []struct { 113 | name string 114 | tokenStart int 115 | sizeVal int 116 | bufferLen int 117 | wantEnd int 118 | wantErr bool 119 | description string 120 | }{ 121 | { 122 | name: "empty literal", 123 | tokenStart: 10, 124 | sizeVal: 0, 125 | bufferLen: 20, 126 | wantEnd: 9, 127 | wantErr: false, 128 | description: "Empty literal should return tokenStart-1", 129 | }, 130 | { 131 | name: "normal case within bounds", 132 | tokenStart: 10, 133 | sizeVal: 5, 134 | bufferLen: 20, 135 | wantEnd: 14, 136 | wantErr: false, 137 | description: "Normal case should return tokenStart+sizeVal-1", 138 | }, 139 | { 140 | name: "exact buffer boundary", 141 | tokenStart: 10, 142 | sizeVal: 10, 143 | bufferLen: 20, 144 | wantEnd: 19, 145 | wantErr: false, 146 | description: "Should handle exact buffer boundary", 147 | }, 148 | { 149 | name: "size exceeds buffer", 150 | tokenStart: 10, 151 | sizeVal: 15, 152 | bufferLen: 20, 153 | wantEnd: 19, 154 | wantErr: false, 155 | description: "Should truncate to buffer length when size exceeds", 156 | }, 157 | { 158 | name: "tokenStart at buffer end", 159 | tokenStart: 20, 160 | sizeVal: 0, 161 | bufferLen: 20, 162 | wantEnd: 19, 163 | wantErr: false, 164 | description: "Should handle tokenStart at buffer end with empty literal", 165 | }, 166 | { 167 | name: "tokenStart past buffer end with size", 168 | tokenStart: 20, 169 | sizeVal: 5, 170 | bufferLen: 20, 171 | wantErr: true, 172 | description: "Should error when tokenStart >= bufferLen with non-zero size", 173 | }, 174 | { 175 | name: "tokenStart past buffer end", 176 | tokenStart: 25, 177 | sizeVal: 0, 178 | bufferLen: 20, 179 | wantEnd: 24, 180 | wantErr: false, 181 | description: "Should handle tokenStart past buffer with empty literal", 182 | }, 183 | } 184 | 185 | for _, tt := range tests { 186 | t.Run(tt.name, func(t *testing.T) { 187 | gotEnd, err := calculateTokenEnd(tt.tokenStart, tt.sizeVal, tt.bufferLen) 188 | 189 | if tt.wantErr { 190 | if err == nil { 191 | t.Errorf("calculateTokenEnd() error = nil, wantErr %v", tt.wantErr) 192 | return 193 | } 194 | } else { 195 | if err != nil { 196 | t.Errorf("calculateTokenEnd() unexpected error = %v", err) 197 | return 198 | } 199 | if gotEnd != tt.wantEnd { 200 | t.Errorf("calculateTokenEnd() = %v, want %v; %s", gotEnd, tt.wantEnd, tt.description) 201 | } 202 | } 203 | }) 204 | } 205 | } 206 | 207 | func contains(s, substr string) bool { 208 | return strings.Contains(s, substr) 209 | } 210 | 211 | func TestParseUIDSearchResponse(t *testing.T) { 212 | t.Parallel() 213 | 214 | tests := []struct { 215 | name string 216 | input string 217 | want []int 218 | wantErr bool 219 | }{ 220 | { 221 | name: "basic search response", 222 | input: "* SEARCH 123 456\r\nA1 OK SEARCH completed\r\n", 223 | want: []int{123, 456}, 224 | }, 225 | { 226 | name: "literal preamble is ignored", 227 | input: strings.Join([]string{ 228 | "+ Ready for additional command text", 229 | "* SEARCH 15461 15469 15470 15485 15491 15497", 230 | "A144 OK UID SEARCH completed", 231 | }, "\r\n"), 232 | want: []int{15461, 15469, 15470, 15485, 15491, 15497}, 233 | }, 234 | { 235 | name: "no search line", 236 | input: "* OK Nothing to see here\r\n", 237 | wantErr: true, 238 | }, 239 | } 240 | 241 | for _, tc := range tests { 242 | t.Run(tc.name, func(t *testing.T) { 243 | t.Parallel() 244 | got, err := parseUIDSearchResponse(tc.input) 245 | if tc.wantErr { 246 | if err == nil { 247 | t.Fatalf("expected error, got nil result %v", got) 248 | } 249 | return 250 | } 251 | if err != nil { 252 | t.Fatalf("parseUIDSearchResponse error: %v", err) 253 | } 254 | if !reflect.DeepEqual(got, tc.want) { 255 | t.Fatalf("got %v want %v", got, tc.want) 256 | } 257 | }) 258 | } 259 | } 260 | 261 | func TestParseFetchResponse(t *testing.T) { 262 | d := &Dialer{} 263 | resp := "* 1 FETCH (UID 7 FLAGS (\\Seen))\r\n" 264 | recs, err := d.ParseFetchResponse(resp) 265 | if err != nil { 266 | t.Fatalf("ParseFetchResponse error: %v", err) 267 | } 268 | if len(recs) != 1 { 269 | t.Fatalf("expected 1 record got %d", len(recs)) 270 | } 271 | r := recs[0] 272 | if len(r) != 4 { 273 | t.Fatalf("expected 4 tokens got %d", len(r)) 274 | } 275 | if r[0].Type != TLiteral || r[0].Str != "UID" { 276 | t.Errorf("unexpected token %#v", r[0]) 277 | } 278 | if r[1].Type != TNumber || r[1].Num != 7 { 279 | t.Errorf("unexpected token %#v", r[1]) 280 | } 281 | if r[2].Type != TLiteral || r[2].Str != "FLAGS" { 282 | t.Errorf("unexpected token %#v", r[2]) 283 | } 284 | if r[3].Type != TContainer || len(r[3].Tokens) != 1 || r[3].Tokens[0].Str != "\\Seen" { 285 | t.Errorf("unexpected token %#v", r[3]) 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /folder.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | ) 9 | 10 | // FolderStats represents statistics for a folder 11 | type FolderStats struct { 12 | Name string 13 | Count int 14 | MaxUID int 15 | Error error 16 | } 17 | 18 | // GetFolders retrieves the list of available folders 19 | func (d *Dialer) GetFolders() (folders []string, err error) { 20 | folders = make([]string, 0) 21 | _, err = d.Exec(`LIST "" "*"`, false, RetryCount, func(line []byte) (err error) { 22 | line = dropNl(line) 23 | if b := bytes.IndexByte(line, '\n'); b != -1 { 24 | folders = append(folders, string(line[b+1:])) 25 | } else { 26 | if len(line) == 0 { 27 | return err 28 | } 29 | i := len(line) - 1 30 | quoted := line[i] == '"' 31 | delim := byte(' ') 32 | if quoted { 33 | delim = '"' 34 | i-- 35 | } 36 | end := i 37 | for i > 0 { 38 | if line[i] == delim { 39 | if !quoted || line[i-1] != '\\' { 40 | break 41 | } 42 | } 43 | i-- 44 | } 45 | folders = append(folders, RemoveSlashes.Replace(string(line[i+1:end+1]))) 46 | } 47 | return err 48 | }) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return folders, nil 54 | } 55 | 56 | // ExamineFolder selects a folder in read-only mode 57 | func (d *Dialer) ExamineFolder(folder string) (err error) { 58 | _, err = d.Exec(`EXAMINE "`+AddSlashes.Replace(folder)+`"`, true, RetryCount, nil) 59 | if err != nil { 60 | return err 61 | } 62 | d.Folder = folder 63 | d.ReadOnly = true 64 | return nil 65 | } 66 | 67 | // SelectFolder selects a folder in read-write mode 68 | func (d *Dialer) SelectFolder(folder string) (err error) { 69 | _, err = d.Exec(`SELECT "`+AddSlashes.Replace(folder)+`"`, true, RetryCount, nil) 70 | if err != nil { 71 | return err 72 | } 73 | d.Folder = folder 74 | d.ReadOnly = false 75 | return nil 76 | } 77 | 78 | // selectAndGetCount executes SELECT command and extracts message count from EXISTS response 79 | func (d *Dialer) selectAndGetCount(folder string) (int, error) { 80 | r, err := d.Exec("SELECT \""+AddSlashes.Replace(folder)+"\"", true, RetryCount, nil) 81 | if err != nil { 82 | return 0, err 83 | } 84 | 85 | // Parse EXISTS response for message count 86 | re := regexp.MustCompile(`\* (\d+) EXISTS`) 87 | matches := re.FindStringSubmatch(r) 88 | if len(matches) > 1 { 89 | if count, parseErr := strconv.Atoi(matches[1]); parseErr == nil { 90 | return count, nil 91 | } 92 | } 93 | 94 | return 0, nil 95 | } 96 | 97 | // GetTotalEmailCount returns the total email count across all folders 98 | func (d *Dialer) GetTotalEmailCount() (count int, err error) { 99 | return d.GetTotalEmailCountStartingFromExcluding("", nil) 100 | } 101 | 102 | // GetTotalEmailCountExcluding returns total email count excluding specified folders 103 | func (d *Dialer) GetTotalEmailCountExcluding(excludedFolders []string) (count int, err error) { 104 | return d.GetTotalEmailCountStartingFromExcluding("", excludedFolders) 105 | } 106 | 107 | // GetTotalEmailCountStartingFrom returns total email count starting from a specific folder 108 | func (d *Dialer) GetTotalEmailCountStartingFrom(startFolder string) (count int, err error) { 109 | return d.GetTotalEmailCountStartingFromExcluding(startFolder, nil) 110 | } 111 | 112 | // GetTotalEmailCountSafe returns total email count with error handling per folder 113 | func (d *Dialer) GetTotalEmailCountSafe() (count int, folderErrors []error, err error) { 114 | return d.GetTotalEmailCountSafeStartingFromExcluding("", nil) 115 | } 116 | 117 | // GetTotalEmailCountSafeExcluding returns total email count excluding folders with error handling 118 | func (d *Dialer) GetTotalEmailCountSafeExcluding(excludedFolders []string) (count int, folderErrors []error, err error) { 119 | return d.GetTotalEmailCountSafeStartingFromExcluding("", excludedFolders) 120 | } 121 | 122 | // GetTotalEmailCountSafeStartingFrom returns total email count starting from folder with error handling 123 | func (d *Dialer) GetTotalEmailCountSafeStartingFrom(startFolder string) (count int, folderErrors []error, err error) { 124 | return d.GetTotalEmailCountSafeStartingFromExcluding(startFolder, nil) 125 | } 126 | 127 | // GetFolderStats returns statistics for all folders 128 | func (d *Dialer) GetFolderStats() ([]FolderStats, error) { 129 | return d.GetFolderStatsStartingFromExcluding("", nil) 130 | } 131 | 132 | // GetFolderStatsExcluding returns statistics for folders excluding specified ones 133 | func (d *Dialer) GetFolderStatsExcluding(excludedFolders []string) ([]FolderStats, error) { 134 | return d.GetFolderStatsStartingFromExcluding("", excludedFolders) 135 | } 136 | 137 | // GetFolderStatsStartingFrom returns statistics for folders starting from a specific one 138 | func (d *Dialer) GetFolderStatsStartingFrom(startFolder string) ([]FolderStats, error) { 139 | return d.GetFolderStatsStartingFromExcluding(startFolder, nil) 140 | } 141 | 142 | // GetTotalEmailCountStartingFromExcluding returns total email count with options for starting folder and exclusions 143 | func (d *Dialer) GetTotalEmailCountStartingFromExcluding(startFolder string, excludedFolders []string) (count int, err error) { 144 | folders, err := d.GetFolders() 145 | if err != nil { 146 | return 0, err 147 | } 148 | 149 | startFound := startFolder == "" 150 | excludeMap := make(map[string]bool) 151 | for _, folder := range excludedFolders { 152 | excludeMap[folder] = true 153 | } 154 | 155 | currentFolder := d.Folder 156 | currentReadOnly := d.ReadOnly 157 | 158 | for _, folder := range folders { 159 | if !startFound { 160 | if folder == startFolder { 161 | startFound = true 162 | } else { 163 | continue 164 | } 165 | } 166 | 167 | if excludeMap[folder] { 168 | continue 169 | } 170 | 171 | folderCount, err := d.selectAndGetCount(folder) 172 | if err == nil { 173 | count += folderCount 174 | } 175 | } 176 | 177 | // Restore original folder state 178 | if currentFolder != "" { 179 | if currentReadOnly { 180 | _ = d.ExamineFolder(currentFolder) 181 | } else { 182 | _ = d.SelectFolder(currentFolder) 183 | } 184 | } 185 | 186 | return count, nil 187 | } 188 | 189 | // GetTotalEmailCountSafeStartingFromExcluding returns total email count with per-folder error handling 190 | func (d *Dialer) GetTotalEmailCountSafeStartingFromExcluding(startFolder string, excludedFolders []string) (count int, folderErrors []error, err error) { 191 | folders, err := d.GetFolders() 192 | if err != nil { 193 | return 0, nil, err 194 | } 195 | 196 | startFound := startFolder == "" 197 | excludeMap := make(map[string]bool) 198 | for _, folder := range excludedFolders { 199 | excludeMap[folder] = true 200 | } 201 | 202 | currentFolder := d.Folder 203 | currentReadOnly := d.ReadOnly 204 | 205 | for _, folder := range folders { 206 | if !startFound { 207 | if folder == startFolder { 208 | startFound = true 209 | } else { 210 | continue 211 | } 212 | } 213 | 214 | if excludeMap[folder] { 215 | continue 216 | } 217 | 218 | folderCount, folderErr := d.selectAndGetCount(folder) 219 | if folderErr != nil { 220 | folderErrors = append(folderErrors, fmt.Errorf("folder %s: %w", folder, folderErr)) 221 | continue 222 | } 223 | count += folderCount 224 | } 225 | 226 | // Restore original folder state 227 | if currentFolder != "" { 228 | if currentReadOnly { 229 | _ = d.ExamineFolder(currentFolder) 230 | } else { 231 | _ = d.SelectFolder(currentFolder) 232 | } 233 | } 234 | 235 | return count, folderErrors, nil 236 | } 237 | 238 | // GetFolderStatsStartingFromExcluding returns detailed statistics for folders with options 239 | func (d *Dialer) GetFolderStatsStartingFromExcluding(startFolder string, excludedFolders []string) ([]FolderStats, error) { 240 | folders, err := d.GetFolders() 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | startFound := startFolder == "" 246 | excludeMap := make(map[string]bool) 247 | for _, folder := range excludedFolders { 248 | excludeMap[folder] = true 249 | } 250 | 251 | currentFolder := d.Folder 252 | currentReadOnly := d.ReadOnly 253 | 254 | var stats []FolderStats 255 | 256 | for _, folder := range folders { 257 | if !startFound { 258 | if folder == startFolder { 259 | startFound = true 260 | } else { 261 | continue 262 | } 263 | } 264 | 265 | if excludeMap[folder] { 266 | continue 267 | } 268 | 269 | stat := FolderStats{Name: folder} 270 | 271 | // Get message count using helper function 272 | count, err := d.selectAndGetCount(folder) 273 | if err != nil { 274 | stat.Error = err 275 | stats = append(stats, stat) 276 | continue 277 | } 278 | stat.Count = count 279 | 280 | // Get highest UID 281 | if stat.Count > 0 { 282 | uidResponse, err := d.Exec("UID SEARCH ALL", true, RetryCount, nil) 283 | if err == nil { 284 | uids, err := parseUIDSearchResponse(uidResponse) 285 | if err == nil && len(uids) > 0 { 286 | stat.MaxUID = uids[len(uids)-1] 287 | } 288 | } 289 | } 290 | 291 | stats = append(stats, stat) 292 | } 293 | 294 | // Restore original folder state 295 | if currentFolder != "" { 296 | if currentReadOnly { 297 | _ = d.ExamineFolder(currentFolder) 298 | } else { 299 | _ = d.SelectFolder(currentFolder) 300 | } 301 | } 302 | 303 | return stats, nil 304 | } 305 | -------------------------------------------------------------------------------- /folder_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // MockDialer for testing EXAMINE/SELECT redundancy 12 | type MockDialer struct { 13 | execCalls []string 14 | examineCount int 15 | selectCount int 16 | responses map[string]string 17 | errors map[string]error 18 | Folder string 19 | ReadOnly bool 20 | } 21 | 22 | func (m *MockDialer) Exec(command string, expectOK bool, retryCount int, handler func(string) error) (string, error) { 23 | m.execCalls = append(m.execCalls, command) 24 | 25 | // Check for configured errors first 26 | if err, ok := m.errors[command]; ok { 27 | if strings.HasPrefix(command, "EXAMINE") { 28 | m.examineCount++ 29 | } 30 | if strings.HasPrefix(command, "SELECT") { 31 | m.selectCount++ 32 | } 33 | return "", err 34 | } 35 | 36 | // Check for custom responses 37 | if response, ok := m.responses[command]; ok { 38 | if strings.HasPrefix(command, "EXAMINE") { 39 | m.examineCount++ 40 | } 41 | if strings.HasPrefix(command, "SELECT") { 42 | m.selectCount++ 43 | } 44 | return response, nil 45 | } 46 | 47 | if strings.HasPrefix(command, "EXAMINE") { 48 | m.examineCount++ 49 | // Default EXAMINE response includes EXISTS count 50 | return "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n* OK [PERMANENTFLAGS ()] Read-only mailbox.\r\n* 23 EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] UIDs valid\r\nA1 OK [READ-ONLY] EXAMINE completed\r\n", nil 51 | } 52 | 53 | if strings.HasPrefix(command, "SELECT") { 54 | m.selectCount++ 55 | // Default SELECT response also includes EXISTS count 56 | return "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Flags permitted.\r\n* 23 EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] UIDs valid\r\nA1 OK [READ-WRITE] SELECT completed\r\n", nil 57 | } 58 | 59 | return "", fmt.Errorf("mock error: no response configured for command: %s", command) 60 | } 61 | 62 | func (m *MockDialer) ExamineFolder(folder string) error { 63 | _, err := m.Exec(`EXAMINE "`+AddSlashes.Replace(folder)+`"`, true, RetryCount, nil) 64 | if err != nil { 65 | return err 66 | } 67 | m.Folder = folder 68 | m.ReadOnly = true 69 | return nil 70 | } 71 | 72 | func (m *MockDialer) selectAndGetCount(folder string) (int, error) { 73 | r, err := m.Exec("SELECT \""+AddSlashes.Replace(folder)+"\"", true, RetryCount, nil) 74 | if err != nil { 75 | return 0, err 76 | } 77 | 78 | // Parse EXISTS response for message count 79 | re := regexp.MustCompile(`\* (\d+) EXISTS`) 80 | matches := re.FindStringSubmatch(r) 81 | if len(matches) > 1 { 82 | if count, parseErr := strconv.Atoi(matches[1]); parseErr == nil { 83 | return count, nil 84 | } 85 | } 86 | 87 | return 0, nil 88 | } 89 | 90 | // TestExamineSelectRedundancy demonstrates an anti-pattern where developers might 91 | // inadvertently call EXAMINE followed by SELECT on the same folder. 92 | // This test shows why using only SELECT is more efficient. 93 | // NOTE: The actual library methods (like selectAndGetCount) are already optimized 94 | // and do not exhibit this redundancy. 95 | func TestExamineSelectRedundancy(t *testing.T) { 96 | tests := []struct { 97 | name string 98 | folders []string 99 | description string 100 | }{ 101 | { 102 | name: "single folder", 103 | folders: []string{"INBOX"}, 104 | description: "Should demonstrate redundant EXAMINE+SELECT anti-pattern for single folder", 105 | }, 106 | { 107 | name: "multiple folders", 108 | folders: []string{"INBOX", "Sent", "Drafts"}, 109 | description: "Should demonstrate redundant EXAMINE+SELECT anti-pattern for multiple folders", 110 | }, 111 | } 112 | 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | mock := &MockDialer{ 116 | execCalls: make([]string, 0), 117 | responses: make(map[string]string), 118 | } 119 | 120 | // Simulate an INEFFICIENT anti-pattern (what NOT to do) 121 | // This demonstrates why library methods use selectAndGetCount instead 122 | for _, folder := range tt.folders { 123 | // Anti-pattern: First calls ExamineFolder (gets folder info read-only) 124 | err := mock.ExamineFolder(folder) 125 | if err != nil { 126 | t.Errorf("ExamineFolder() error = %v", err) 127 | continue 128 | } 129 | 130 | // Anti-pattern: Then immediately calls SELECT (gets folder info + write access) 131 | // This is redundant because SELECT provides everything EXAMINE does, plus write access 132 | _, err = mock.Exec("SELECT \""+AddSlashes.Replace(folder)+"\"", true, RetryCount, nil) 133 | if err != nil { 134 | t.Errorf("SELECT error = %v", err) 135 | } 136 | } 137 | 138 | // Verify the redundancy 139 | expectedCalls := len(tt.folders) * 2 // Both EXAMINE and SELECT for each folder 140 | if len(mock.execCalls) != expectedCalls { 141 | t.Errorf("Expected %d total calls, got %d", expectedCalls, len(mock.execCalls)) 142 | } 143 | 144 | if mock.examineCount != len(tt.folders) { 145 | t.Errorf("Expected %d EXAMINE calls, got %d", len(tt.folders), mock.examineCount) 146 | } 147 | 148 | if mock.selectCount != len(tt.folders) { 149 | t.Errorf("Expected %d SELECT calls, got %d", len(tt.folders), mock.selectCount) 150 | } 151 | 152 | // Verify that both EXAMINE and SELECT were called for each folder 153 | for i, folder := range tt.folders { 154 | examineCall := `EXAMINE "` + AddSlashes.Replace(folder) + `"` 155 | selectCall := `SELECT "` + AddSlashes.Replace(folder) + `"` 156 | 157 | if !contains(mock.execCalls[i*2], "EXAMINE") { 158 | t.Errorf("Expected EXAMINE call for folder %s, got %s", folder, mock.execCalls[i*2]) 159 | } 160 | 161 | if !contains(mock.execCalls[i*2+1], "SELECT") { 162 | t.Errorf("Expected SELECT call for folder %s, got %s", folder, mock.execCalls[i*2+1]) 163 | } 164 | 165 | _ = examineCall 166 | _ = selectCall 167 | } 168 | 169 | t.Logf("Anti-pattern demonstrated: %d folders resulted in %d calls (%d EXAMINE + %d SELECT)", 170 | len(tt.folders), len(mock.execCalls), mock.examineCount, mock.selectCount) 171 | t.Logf("✅ Optimization: Library already uses only %d SELECT calls via selectAndGetCount()", len(tt.folders)) 172 | }) 173 | } 174 | } 175 | 176 | // TestEfficientFolderAccess demonstrates the optimized approach used by the library. 177 | // This is what selectAndGetCount() and other library methods actually do. 178 | func TestEfficientFolderAccess(t *testing.T) { 179 | tests := []struct { 180 | name string 181 | folders []string 182 | description string 183 | }{ 184 | { 185 | name: "single folder optimized", 186 | folders: []string{"INBOX"}, 187 | description: "Library uses only SELECT to get EXISTS count (no EXAMINE needed)", 188 | }, 189 | { 190 | name: "multiple folders optimized", 191 | folders: []string{"INBOX", "Sent", "Drafts"}, 192 | description: "Library uses only SELECT for all folders (50% fewer IMAP commands)", 193 | }, 194 | } 195 | 196 | for _, tt := range tests { 197 | t.Run(tt.name, func(t *testing.T) { 198 | mock := &MockDialer{ 199 | execCalls: make([]string, 0), 200 | responses: make(map[string]string), 201 | } 202 | 203 | // ✅ EFFICIENT approach: Use only SELECT (what the library actually does) 204 | // This is equivalent to calling selectAndGetCount() for each folder 205 | for _, folder := range tt.folders { 206 | _, err := mock.Exec("SELECT \""+AddSlashes.Replace(folder)+"\"", true, RetryCount, nil) 207 | if err != nil { 208 | t.Errorf("SELECT error = %v", err) 209 | } 210 | } 211 | 212 | // Verify efficiency 213 | expectedCalls := len(tt.folders) // Only SELECT for each folder 214 | if len(mock.execCalls) != expectedCalls { 215 | t.Errorf("Expected %d total calls, got %d", expectedCalls, len(mock.execCalls)) 216 | } 217 | 218 | if mock.examineCount != 0 { 219 | t.Errorf("Expected 0 EXAMINE calls, got %d", mock.examineCount) 220 | } 221 | 222 | if mock.selectCount != len(tt.folders) { 223 | t.Errorf("Expected %d SELECT calls, got %d", len(tt.folders), mock.selectCount) 224 | } 225 | 226 | t.Logf("✅ Efficient approach (actual library behavior): %d folders = %d calls (only SELECT)", 227 | len(tt.folders), len(mock.execCalls)) 228 | t.Logf("📈 Performance: 50%% fewer IMAP commands vs anti-pattern") 229 | }) 230 | } 231 | } 232 | 233 | func TestSelectAndGetCount(t *testing.T) { 234 | tests := []struct { 235 | name string 236 | folder string 237 | response string 238 | expectedCount int 239 | expectedError bool 240 | description string 241 | }{ 242 | { 243 | name: "successful count extraction", 244 | folder: "INBOX", 245 | response: "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n" + 246 | "* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Flags permitted.\r\n" + 247 | "* 23 EXISTS\r\n" + 248 | "* 0 RECENT\r\n" + 249 | "* OK [UIDVALIDITY 1] UIDs valid\r\n" + 250 | "A1 OK [READ-WRITE] SELECT completed\r\n", 251 | expectedCount: 23, 252 | expectedError: false, 253 | description: "Should extract EXISTS count from SELECT response", 254 | }, 255 | { 256 | name: "zero count extraction", 257 | folder: "Empty", 258 | response: "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n" + 259 | "* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Flags permitted.\r\n" + 260 | "* 0 EXISTS\r\n" + 261 | "* 0 RECENT\r\n" + 262 | "* OK [UIDVALIDITY 1] UIDs valid\r\n" + 263 | "A1 OK [READ-WRITE] SELECT completed\r\n", 264 | expectedCount: 0, 265 | expectedError: false, 266 | description: "Should handle empty folders correctly", 267 | }, 268 | { 269 | name: "large count extraction", 270 | folder: "Archive", 271 | response: "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n" + 272 | "* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Flags permitted.\r\n" + 273 | "* 1500 EXISTS\r\n" + 274 | "* 0 RECENT\r\n" + 275 | "* OK [UIDVALIDITY 1] UIDs valid\r\n" + 276 | "A1 OK [READ-WRITE] SELECT completed\r\n", 277 | expectedCount: 1500, 278 | expectedError: false, 279 | description: "Should handle large message counts", 280 | }, 281 | { 282 | name: "exec failure", 283 | folder: "NonExistent", 284 | response: "", 285 | expectedCount: 0, 286 | expectedError: true, 287 | description: "Should handle SELECT command failures", 288 | }, 289 | } 290 | 291 | for _, tt := range tests { 292 | t.Run(tt.name, func(t *testing.T) { 293 | mock := &MockDialer{ 294 | execCalls: make([]string, 0), 295 | responses: make(map[string]string), 296 | errors: make(map[string]error), 297 | } 298 | 299 | if tt.expectedError { 300 | mock.errors["SELECT \""+AddSlashes.Replace(tt.folder)+"\""] = fmt.Errorf("folder not found: %s", tt.folder) 301 | } else { 302 | mock.responses["SELECT \""+AddSlashes.Replace(tt.folder)+"\""] = tt.response 303 | } 304 | 305 | count, err := mock.selectAndGetCount(tt.folder) 306 | 307 | if tt.expectedError { 308 | if err == nil { 309 | t.Errorf("selectAndGetCount() expected error but got none") 310 | } 311 | } else { 312 | if err != nil { 313 | t.Errorf("selectAndGetCount() unexpected error: %v", err) 314 | } 315 | if count != tt.expectedCount { 316 | t.Errorf("selectAndGetCount() got count %d, want %d", count, tt.expectedCount) 317 | } 318 | } 319 | 320 | // Verify only one SELECT call was made 321 | if len(mock.execCalls) != 1 { 322 | t.Errorf("selectAndGetCount() made %d calls, expected 1", len(mock.execCalls)) 323 | } 324 | 325 | if mock.selectCount != 1 { 326 | t.Errorf("selectAndGetCount() made %d SELECT calls, expected 1", mock.selectCount) 327 | } 328 | 329 | if mock.examineCount != 0 { 330 | t.Errorf("selectAndGetCount() made %d EXAMINE calls, expected 0", mock.examineCount) 331 | } 332 | }) 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "unicode" 9 | ) 10 | 11 | const ( 12 | nl = "\r\n" 13 | TimeFormat = "_2-Jan-2006 15:04:05 -0700" 14 | ) 15 | 16 | var ( 17 | atom = regexp.MustCompile(`{\d+}$`) 18 | fetchLineStartRE = regexp.MustCompile(`(?m)^\* \d+ FETCH`) 19 | ) 20 | 21 | // Token represents a parsed IMAP token 22 | type Token struct { 23 | Type TType 24 | Str string 25 | Num int 26 | Tokens []*Token 27 | } 28 | 29 | // TType represents the type of an IMAP token 30 | type TType uint8 31 | 32 | const ( 33 | TUnset TType = iota 34 | TAtom 35 | TNumber 36 | TLiteral 37 | TQuoted 38 | TNil 39 | TContainer 40 | ) 41 | 42 | type tokenContainer *[]*Token 43 | 44 | // calculateTokenEnd calculates the end position of a literal token based on size and buffer constraints 45 | func calculateTokenEnd(tokenStart, sizeVal, bufferLen int) (int, error) { 46 | switch { 47 | case tokenStart >= bufferLen: 48 | if sizeVal == 0 { 49 | return tokenStart - 1, nil // Results in empty string for r[tokenStart:tokenEnd+1] 50 | } 51 | return 0, fmt.Errorf("TAtom: literal size %d but tokenStart %d is at/past end of buffer %d", sizeVal, tokenStart, bufferLen) 52 | case tokenStart+sizeVal > bufferLen: 53 | return bufferLen - 1, nil // Taking available data 54 | default: 55 | return tokenStart + sizeVal - 1, nil // Normal case: sizeVal fits 56 | } 57 | } 58 | 59 | // parseFetchTokens parses IMAP FETCH response tokens 60 | func parseFetchTokens(r string) ([]*Token, error) { 61 | tokens := make([]*Token, 0) 62 | 63 | currentToken := TUnset 64 | tokenStart := 0 65 | tokenEnd := 0 66 | depth := 0 67 | container := make([]tokenContainer, 4) 68 | container[0] = &tokens 69 | 70 | pushToken := func() *Token { 71 | var t *Token 72 | switch currentToken { 73 | case TQuoted: 74 | t = &Token{ 75 | Type: currentToken, 76 | Str: RemoveSlashes.Replace(string(r[tokenStart : tokenEnd+1])), 77 | } 78 | case TLiteral: 79 | s := string(r[tokenStart : tokenEnd+1]) 80 | num, err := strconv.Atoi(s) 81 | if err == nil { 82 | t = &Token{ 83 | Type: TNumber, 84 | Num: num, 85 | } 86 | } else { 87 | if s == "NIL" { 88 | t = &Token{ 89 | Type: TNil, 90 | } 91 | } else { 92 | t = &Token{ 93 | Type: TLiteral, 94 | Str: s, 95 | } 96 | } 97 | } 98 | case TAtom: 99 | t = &Token{ 100 | Type: currentToken, 101 | Str: string(r[tokenStart : tokenEnd+1]), 102 | } 103 | case TContainer: 104 | t = &Token{ 105 | Type: currentToken, 106 | Tokens: make([]*Token, 0, 1), 107 | } 108 | } 109 | 110 | if t != nil { 111 | *container[depth] = append(*container[depth], t) 112 | } 113 | currentToken = TUnset 114 | 115 | return t 116 | } 117 | 118 | l := len(r) 119 | i := 0 120 | for i < l { 121 | b := r[i] 122 | 123 | switch currentToken { 124 | case TQuoted: 125 | switch b { 126 | case '"': 127 | tokenEnd = i - 1 128 | pushToken() 129 | goto Cont 130 | case '\\': 131 | i++ 132 | goto Cont 133 | } 134 | case TLiteral: 135 | switch { 136 | case IsLiteral(rune(b)): 137 | default: 138 | tokenEnd = i - 1 139 | pushToken() 140 | } 141 | case TAtom: 142 | switch { 143 | case unicode.IsDigit(rune(b)): 144 | // Still accumulating digits for size, main loop's i++ will advance 145 | default: // Should be '}' 146 | tokenEndOfSize := i // Current 'i' is at '}' 147 | // tokenStart for size was set when '{' was seen. r[tokenStart:tokenEndOfSize] is the size string. 148 | sizeVal, err := strconv.Atoi(string(r[tokenStart:tokenEndOfSize])) 149 | if err != nil { 150 | return nil, fmt.Errorf("TAtom size Atoi failed for '%s': %w", string(r[tokenStart:tokenEndOfSize]), err) 151 | } 152 | 153 | i++ // Advance 'i' past '}' to the start of actual literal data 154 | 155 | if i < len(r) && r[i] == '\r' { 156 | i++ 157 | } 158 | if i < len(r) && r[i] == '\n' { 159 | i++ 160 | } 161 | 162 | tokenStart = i // tokenStart is now for the literal data itself 163 | 164 | // Calculate token end position with boundary checks 165 | tokenEnd, err = calculateTokenEnd(tokenStart, sizeVal, len(r)) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | i = tokenEnd // Move main loop cursor to the end of the literal data 171 | pushToken() // Push the TAtom token 172 | } 173 | } 174 | 175 | if currentToken == TUnset { // If no token is being actively parsed 176 | switch { 177 | case b == '"': 178 | currentToken = TQuoted 179 | tokenStart = i + 1 180 | case IsLiteral(rune(b)): 181 | currentToken = TLiteral 182 | tokenStart = i 183 | case b == '{': // Start of a new literal 184 | currentToken = TAtom 185 | tokenStart = i + 1 // tokenStart for the size digits 186 | case b == '(': 187 | currentToken = TContainer 188 | t := pushToken() // push any pending token before starting container 189 | depth++ 190 | // Grow container stack if needed 191 | if depth >= len(container) { 192 | newContainer := make([]tokenContainer, depth*2) 193 | copy(newContainer, container) 194 | container = newContainer 195 | } 196 | container[depth] = &t.Tokens 197 | case b == ')': 198 | if depth == 0 { // Unmatched ')' 199 | return nil, fmt.Errorf("unmatched ')' at char %d in %s", i, r) 200 | } 201 | pushToken() // push any pending token before closing container 202 | depth-- 203 | } 204 | } 205 | 206 | Cont: 207 | if depth < 0 { 208 | break 209 | } 210 | i++ 211 | if i >= l { // If we've processed all characters or gone past 212 | if currentToken != TUnset { // Only push if there's a pending token 213 | tokenEnd = l - 1 // The last character is at index l-1 214 | pushToken() 215 | } 216 | } 217 | } 218 | 219 | if depth != 0 { 220 | return nil, fmt.Errorf("mismatched parentheses, depth %d at end of parsing %s", depth, r) 221 | } 222 | 223 | if len(tokens) == 1 && tokens[0].Type == TContainer { 224 | tokens = tokens[0].Tokens 225 | } 226 | 227 | return tokens, nil 228 | } 229 | 230 | // ParseFetchResponse parses a multi-line FETCH response 231 | func (d *Dialer) ParseFetchResponse(responseBody string) (records [][]*Token, err error) { 232 | records = make([][]*Token, 0) 233 | trimmedResponseBody := strings.TrimSpace(responseBody) 234 | if trimmedResponseBody == "" { 235 | return records, nil 236 | } 237 | 238 | locs := fetchLineStartRE.FindAllStringIndex(trimmedResponseBody, -1) 239 | 240 | if locs == nil { 241 | // No FETCH lines found by regex. 242 | // Try to parse as a single line if it starts with "* ". 243 | if strings.HasPrefix(trimmedResponseBody, "* ") { 244 | currentLineToProcess := trimmedResponseBody 245 | // Standard parsing logic for a single line 246 | if !strings.HasPrefix(currentLineToProcess, "* ") { 247 | return nil, fmt.Errorf("unable to parse Fetch line (expected '* ' prefix): %#v", currentLineToProcess) 248 | } 249 | rest := currentLineToProcess[2:] 250 | idx := strings.IndexByte(rest, ' ') 251 | if idx == -1 { 252 | return nil, fmt.Errorf("unable to parse Fetch line (no space after seq number): %#v", currentLineToProcess) 253 | } 254 | seqNumStr := rest[:idx] 255 | if _, convErr := strconv.Atoi(seqNumStr); convErr != nil { 256 | return nil, fmt.Errorf("unable to parse Fetch line (invalid seq num %s): %#v: %w", seqNumStr, currentLineToProcess, convErr) 257 | } 258 | rest = strings.TrimSpace(rest[idx+1:]) 259 | if !strings.HasPrefix(rest, "FETCH ") { 260 | return nil, fmt.Errorf("unable to parse Fetch line (expected 'FETCH ' prefix after seq num): %#v", currentLineToProcess) 261 | } 262 | fetchContent := rest[len("FETCH "):] 263 | tokens, parseErr := parseFetchTokens(fetchContent) 264 | if parseErr != nil { 265 | return nil, fmt.Errorf("token parsing failed for line part [%s] from original line [%s]: %w", fetchContent, currentLineToProcess, parseErr) 266 | } 267 | records = append(records, tokens) 268 | return records, nil 269 | } 270 | // If not starting with "* " and no FETCH lines found by regex, return empty or error. 271 | return records, nil 272 | } 273 | 274 | for i, loc := range locs { 275 | start := loc[0] 276 | end := len(trimmedResponseBody) 277 | if i+1 < len(locs) { 278 | end = locs[i+1][0] 279 | } 280 | line := trimmedResponseBody[start:end] 281 | currentLineToProcess := strings.TrimSpace(line) 282 | 283 | if len(currentLineToProcess) == 0 { 284 | continue 285 | } 286 | 287 | if !strings.HasPrefix(currentLineToProcess, "* ") { 288 | return nil, fmt.Errorf("unable to parse Fetch line (expected '* ' prefix, regex mismatch?): %#v", currentLineToProcess) 289 | } 290 | rest := currentLineToProcess[2:] 291 | idx := strings.IndexByte(rest, ' ') 292 | if idx == -1 { 293 | return nil, fmt.Errorf("unable to parse Fetch line (no space after seq number, regex mismatch?): %#v", currentLineToProcess) 294 | } 295 | 296 | seqNumStr := rest[:idx] 297 | if _, convErr := strconv.Atoi(seqNumStr); convErr != nil { 298 | return nil, fmt.Errorf("unable to parse Fetch line (invalid seq num %s): %#v: %w", seqNumStr, currentLineToProcess, convErr) 299 | } 300 | 301 | rest = strings.TrimSpace(rest[idx+1:]) 302 | if !strings.HasPrefix(rest, "FETCH ") { 303 | return nil, fmt.Errorf("unable to parse Fetch line (expected 'FETCH ' prefix after seq num, regex mismatch?): %#v", currentLineToProcess) 304 | } 305 | 306 | fetchContent := rest[len("FETCH "):] 307 | tokens, err := parseFetchTokens(fetchContent) 308 | if err != nil { 309 | return nil, fmt.Errorf("token parsing failed for line part [%s] from original line [%s]: %w", fetchContent, currentLineToProcess, err) 310 | } 311 | records = append(records, tokens) 312 | } 313 | return records, nil 314 | } 315 | 316 | // parseUIDSearchResponse parses UID SEARCH command responses 317 | func parseUIDSearchResponse(r string) ([]int, error) { 318 | normalized := strings.ReplaceAll(r, nl, "\n") 319 | for rawLine := range strings.SplitSeq(normalized, "\n") { 320 | line := strings.TrimSpace(rawLine) 321 | if line == "" { 322 | continue 323 | } 324 | 325 | fields := strings.Fields(line) 326 | if len(fields) < 2 || fields[0] != "*" || !strings.EqualFold(fields[1], "SEARCH") { 327 | continue 328 | } 329 | 330 | uids := make([]int, 0, len(fields)-2) 331 | for _, f := range fields[2:] { 332 | u, err := strconv.Atoi(f) 333 | if err != nil { 334 | return nil, fmt.Errorf("parse uid %q: %w", f, err) 335 | } 336 | uids = append(uids, u) 337 | } 338 | return uids, nil 339 | } 340 | 341 | return nil, fmt.Errorf("invalid response: %q", strings.TrimSpace(r)) 342 | } 343 | 344 | // IsLiteral checks if a rune is valid for a literal token 345 | func IsLiteral(b rune) bool { 346 | switch { 347 | case unicode.IsDigit(b), 348 | unicode.IsLetter(b), 349 | b == '\\', 350 | b == '.', 351 | b == '[', 352 | b == ']': 353 | return true 354 | } 355 | return false 356 | } 357 | 358 | // GetTokenName returns the string name of a token type 359 | func GetTokenName(tokenType TType) string { 360 | switch tokenType { 361 | case TUnset: 362 | return "TUnset" 363 | case TAtom: 364 | return "TAtom" 365 | case TNumber: 366 | return "TNumber" 367 | case TLiteral: 368 | return "TLiteral" 369 | case TQuoted: 370 | return "TQuoted" 371 | case TNil: 372 | return "TNil" 373 | case TContainer: 374 | return "TContainer" 375 | } 376 | return "" 377 | } 378 | 379 | // String returns a string representation of a Token 380 | func (t Token) String() string { 381 | tokenType := GetTokenName(t.Type) 382 | switch t.Type { 383 | case TUnset, TNil: 384 | return tokenType 385 | case TAtom, TQuoted: 386 | return fmt.Sprintf("(%s, len %d, chars %d %#v)", tokenType, len(t.Str), len([]rune(t.Str)), t.Str) 387 | case TNumber: 388 | return fmt.Sprintf("(%s %d)", tokenType, t.Num) 389 | case TLiteral: 390 | return fmt.Sprintf("(%s %s)", tokenType, t.Str) 391 | case TContainer: 392 | return fmt.Sprintf("(%s children: %s)", tokenType, t.Tokens) 393 | } 394 | return "" 395 | } 396 | 397 | // CheckType validates that a token is one of the acceptable types 398 | func (d *Dialer) CheckType(token *Token, acceptableTypes []TType, tks []*Token, loc string, v ...interface{}) (err error) { 399 | ok := false 400 | for _, a := range acceptableTypes { 401 | if token.Type == a { 402 | ok = true 403 | break 404 | } 405 | } 406 | if !ok { 407 | types := "" 408 | for i, a := range acceptableTypes { 409 | if i != 0 { 410 | types += "|" 411 | } 412 | types += GetTokenName(a) 413 | } 414 | err = fmt.Errorf("IMAP%d:%s: expected %s token %s, got %+v in %v", d.ConnNum, d.Folder, types, fmt.Sprintf(loc, v...), token, tks) 415 | } 416 | 417 | return err 418 | } 419 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/pem" 11 | "fmt" 12 | "math/big" 13 | "net" 14 | "strings" 15 | "sync/atomic" 16 | "testing" 17 | "time" 18 | ) 19 | 20 | type mockIMAPServer struct { 21 | listener net.Listener 22 | address string 23 | authAttempts int32 24 | validUser string 25 | validPass string 26 | failAuth bool 27 | failConnection bool 28 | responses map[string]string 29 | tlsConfig *tls.Config 30 | } 31 | 32 | func newMockIMAPServer(validUser, validPass string) (*mockIMAPServer, error) { 33 | // Generate a certificate for testing 34 | cert, err := generateSelfSignedCertificate() 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to generate certificate: %v", err) 37 | } 38 | 39 | tlsConfig := &tls.Config{ 40 | Certificates: []tls.Certificate{cert}, 41 | } 42 | 43 | listener, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to create TLS listener: %v", err) 46 | } 47 | 48 | server := &mockIMAPServer{ 49 | listener: listener, 50 | address: listener.Addr().String(), 51 | validUser: validUser, 52 | validPass: validPass, 53 | responses: make(map[string]string), 54 | tlsConfig: tlsConfig, 55 | } 56 | 57 | go server.serve() 58 | return server, nil 59 | } 60 | 61 | func (s *mockIMAPServer) serve() { 62 | for { 63 | conn, err := s.listener.Accept() 64 | if err != nil { 65 | return 66 | } 67 | go s.handleConnection(conn) 68 | } 69 | } 70 | 71 | func (s *mockIMAPServer) handleConnection(conn net.Conn) { 72 | defer conn.Close() 73 | 74 | if s.failConnection { 75 | // Simulate connection failure 76 | return 77 | } 78 | 79 | reader := bufio.NewReader(conn) 80 | writer := bufio.NewWriter(conn) 81 | 82 | writer.WriteString("* OK IMAP4rev1 Mock Server Ready\r\n") 83 | writer.Flush() 84 | 85 | for { 86 | line, err := reader.ReadString('\n') 87 | if err != nil { 88 | return 89 | } 90 | 91 | line = strings.TrimSpace(line) 92 | parts := strings.Fields(line) 93 | if len(parts) < 2 { 94 | continue 95 | } 96 | 97 | tag := parts[0] 98 | command := strings.ToUpper(parts[1]) 99 | 100 | switch command { 101 | case "LOGIN": 102 | atomic.AddInt32(&s.authAttempts, 1) 103 | if s.failAuth { 104 | writer.WriteString(fmt.Sprintf("%s NO LOGIN failed\r\n", tag)) 105 | } else if len(parts) >= 4 { 106 | // Extract username and password (removing quotes) 107 | username := strings.Trim(parts[2], `"`) 108 | password := strings.Trim(parts[3], `"`) 109 | 110 | if username == s.validUser && password == s.validPass { 111 | writer.WriteString(fmt.Sprintf("%s OK LOGIN completed\r\n", tag)) 112 | } else { 113 | writer.WriteString(fmt.Sprintf("%s NO [AUTHENTICATIONFAILED] Authentication failed\r\n", tag)) 114 | } 115 | } else { 116 | writer.WriteString(fmt.Sprintf("%s BAD Invalid LOGIN command\r\n", tag)) 117 | } 118 | 119 | case "AUTHENTICATE": 120 | atomic.AddInt32(&s.authAttempts, 1) 121 | if s.failAuth { 122 | writer.WriteString(fmt.Sprintf("%s NO AUTHENTICATE failed\r\n", tag)) 123 | } else { 124 | // Simplified XOAUTH2 handling 125 | writer.WriteString(fmt.Sprintf("%s OK AUTHENTICATE completed\r\n", tag)) 126 | } 127 | 128 | case "CAPABILITY": 129 | writer.WriteString("* CAPABILITY IMAP4rev1 LOGIN AUTHENTICATE\r\n") 130 | writer.WriteString(fmt.Sprintf("%s OK CAPABILITY completed\r\n", tag)) 131 | 132 | case "LOGOUT": 133 | writer.WriteString("* BYE IMAP4rev1 Server logging out\r\n") 134 | writer.WriteString(fmt.Sprintf("%s OK LOGOUT completed\r\n", tag)) 135 | return 136 | 137 | default: 138 | writer.WriteString(fmt.Sprintf("%s OK %s completed\r\n", tag, command)) 139 | } 140 | 141 | writer.Flush() 142 | } 143 | } 144 | 145 | func (s *mockIMAPServer) GetAuthAttempts() int { 146 | return int(atomic.LoadInt32(&s.authAttempts)) 147 | } 148 | 149 | func (s *mockIMAPServer) ResetAuthAttempts() { 150 | atomic.StoreInt32(&s.authAttempts, 0) 151 | } 152 | 153 | func (s *mockIMAPServer) Close() { 154 | s.listener.Close() 155 | } 156 | 157 | func (s *mockIMAPServer) GetHost() string { 158 | host, _, _ := net.SplitHostPort(s.address) 159 | return host 160 | } 161 | 162 | func (s *mockIMAPServer) GetPort() int { 163 | _, portStr, _ := net.SplitHostPort(s.address) 164 | var port int 165 | fmt.Sscanf(portStr, "%d", &port) 166 | return port 167 | } 168 | 169 | func generateSelfSignedCertificate() (tls.Certificate, error) { 170 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 171 | if err != nil { 172 | return tls.Certificate{}, err 173 | } 174 | 175 | template := x509.Certificate{ 176 | SerialNumber: big.NewInt(1), 177 | Subject: pkix.Name{ 178 | Organization: []string{"Test Co"}, 179 | }, 180 | NotBefore: time.Now(), 181 | NotAfter: time.Now().Add(365 * 24 * time.Hour), 182 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 183 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 184 | BasicConstraintsValid: true, 185 | IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, 186 | } 187 | 188 | certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) 189 | if err != nil { 190 | return tls.Certificate{}, err 191 | } 192 | 193 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) 194 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 195 | 196 | return tls.X509KeyPair(certPEM, keyPEM) 197 | } 198 | 199 | // TestAuthenticationNoRecursion verifies that authentication failures don't cause recursion 200 | func TestAuthenticationNoRecursion(t *testing.T) { 201 | // Save original settings 202 | originalVerbose := Verbose 203 | originalRetryCount := RetryCount 204 | originalTLSSkipVerify := TLSSkipVerify 205 | 206 | // Configure for testing 207 | Verbose = false 208 | RetryCount = 3 // Set retry count to verify it's not used for auth 209 | TLSSkipVerify = true // Skip verification for test cert 210 | 211 | defer func() { 212 | Verbose = originalVerbose 213 | RetryCount = originalRetryCount 214 | TLSSkipVerify = originalTLSSkipVerify 215 | }() 216 | 217 | server, err := newMockIMAPServer("testuser", "testpass") 218 | if err != nil { 219 | t.Fatalf("Failed to create mock server: %v", err) 220 | } 221 | defer server.Close() 222 | 223 | // Test 1: Successful authentication 224 | t.Run("SuccessfulAuth", func(t *testing.T) { 225 | server.ResetAuthAttempts() 226 | 227 | d, err := New("testuser", "testpass", server.GetHost(), server.GetPort()) 228 | if err != nil { 229 | t.Errorf("Expected successful connection, got error: %v", err) 230 | } 231 | if d != nil { 232 | d.Close() 233 | } 234 | 235 | // Should only attempt auth once 236 | if attempts := server.GetAuthAttempts(); attempts != 1 { 237 | t.Errorf("Expected 1 auth attempt, got %d", attempts) 238 | } 239 | }) 240 | 241 | // Test 2: Failed authentication should not retry 242 | t.Run("FailedAuthNoRetry", func(t *testing.T) { 243 | server.ResetAuthAttempts() 244 | 245 | // Use a channel to detect if the function returns in reasonable time 246 | done := make(chan bool, 1) 247 | var connErr error 248 | 249 | go func() { 250 | _, connErr = New("testuser", "wrongpass", server.GetHost(), server.GetPort()) 251 | done <- true 252 | }() 253 | 254 | select { 255 | case <-done: 256 | // Good, function returned 257 | if connErr == nil { 258 | t.Error("Expected authentication error, got nil") 259 | } 260 | case <-time.After(2 * time.Second): 261 | t.Error("Authentication appears to be stuck in recursion") 262 | } 263 | 264 | // Should only attempt auth once despite RetryCount being 3 265 | attempts := server.GetAuthAttempts() 266 | if attempts != 1 { 267 | t.Errorf("Expected 1 auth attempt (no retry), got %d", attempts) 268 | } 269 | }) 270 | 271 | // Test 3: XOAUTH2 authentication should also not retry 272 | t.Run("XOAuth2NoRetry", func(t *testing.T) { 273 | server.ResetAuthAttempts() 274 | server.failAuth = true 275 | defer func() { server.failAuth = false }() 276 | 277 | done := make(chan bool, 1) 278 | var connErr error 279 | 280 | go func() { 281 | _, connErr = NewWithOAuth2("testuser", "token", server.GetHost(), server.GetPort()) 282 | done <- true 283 | }() 284 | 285 | select { 286 | case <-done: 287 | if connErr == nil { 288 | t.Error("Expected authentication error, got nil") 289 | } 290 | case <-time.After(2 * time.Second): 291 | t.Error("XOAUTH2 authentication appears to be stuck in recursion") 292 | } 293 | 294 | // Should only attempt auth once 295 | attempts := server.GetAuthAttempts() 296 | if attempts != 1 { 297 | t.Errorf("Expected 1 XOAUTH2 auth attempt (no retry), got %d", attempts) 298 | } 299 | }) 300 | } 301 | 302 | // TestConnectionRetry verifies that connection failures still retry 303 | func TestConnectionRetry(t *testing.T) { 304 | // Save original settings 305 | originalVerbose := Verbose 306 | originalRetryCount := RetryCount 307 | originalTLSSkipVerify := TLSSkipVerify 308 | 309 | // Configure for testing 310 | Verbose = false 311 | RetryCount = 2 // Reduce for faster test 312 | TLSSkipVerify = true 313 | 314 | defer func() { 315 | Verbose = originalVerbose 316 | RetryCount = originalRetryCount 317 | TLSSkipVerify = originalTLSSkipVerify 318 | }() 319 | 320 | start := time.Now() 321 | _, err := New("user", "pass", "127.0.0.1", 59999) // Use unlikely port 322 | elapsed := time.Since(start) 323 | 324 | if err == nil { 325 | t.Error("Expected connection error, got nil") 326 | } 327 | 328 | // With retries, it should take some time (but not too long) 329 | // Each retry has a delay, so it should take at least a second 330 | if elapsed < 100*time.Millisecond { 331 | t.Error("Connection failed too quickly, retries may not be working") 332 | } 333 | 334 | // But it shouldn't take forever (indicates no infinite loop) 335 | if elapsed > 30*time.Second { 336 | t.Error("Connection took too long, possible infinite loop") 337 | } 338 | } 339 | 340 | // TestReconnectWithBadCredentials verifies Reconnect handles auth failures properly 341 | func TestReconnectWithBadCredentials(t *testing.T) { 342 | // Save original settings 343 | originalVerbose := Verbose 344 | originalTLSSkipVerify := TLSSkipVerify 345 | 346 | // Configure for testing 347 | Verbose = false 348 | TLSSkipVerify = true 349 | 350 | defer func() { 351 | Verbose = originalVerbose 352 | TLSSkipVerify = originalTLSSkipVerify 353 | }() 354 | 355 | server, err := newMockIMAPServer("testuser", "testpass") 356 | if err != nil { 357 | t.Fatalf("Failed to create mock server: %v", err) 358 | } 359 | defer server.Close() 360 | 361 | d, err := New("testuser", "testpass", server.GetHost(), server.GetPort()) 362 | if err != nil { 363 | t.Fatalf("Failed to create initial connection: %v", err) 364 | } 365 | defer d.Close() 366 | 367 | // Change password to simulate bad credentials on reconnect 368 | d.Password = "wrongpass" 369 | server.ResetAuthAttempts() 370 | 371 | // Attempt reconnect with bad credentials 372 | err = d.Reconnect() 373 | if err == nil { 374 | t.Error("Expected reconnect to fail with bad credentials") 375 | } 376 | 377 | // Should only attempt auth once 378 | attempts := server.GetAuthAttempts() 379 | if attempts != 1 { 380 | t.Errorf("Expected 1 auth attempt on reconnect, got %d", attempts) 381 | } 382 | 383 | // Connection should be closed after failed auth 384 | if d.Connected { 385 | t.Error("Connection should be closed after failed reconnect") 386 | } 387 | } 388 | 389 | // TestSimpleAuthRecursionCheck does a simple test without mock server 390 | func TestSimpleAuthRecursionCheck(t *testing.T) { 391 | // Save original settings 392 | originalVerbose := Verbose 393 | originalRetryCount := RetryCount 394 | originalDialTimeout := DialTimeout 395 | 396 | // Configure for testing 397 | Verbose = false 398 | RetryCount = 2 // Reduce retry count for faster test 399 | DialTimeout = 1 * time.Second // Set short timeout to avoid long waits 400 | 401 | defer func() { 402 | Verbose = originalVerbose 403 | RetryCount = originalRetryCount 404 | DialTimeout = originalDialTimeout 405 | }() 406 | 407 | // Try to connect to localhost on a port that's definitely not listening 408 | // This should fail quickly and retry according to RetryCount 409 | start := time.Now() 410 | _, err := New("test", "test", "127.0.0.1", 54321) // Use localhost with random port 411 | elapsed := time.Since(start) 412 | 413 | if err == nil { 414 | t.Error("Expected error connecting to non-listening port") 415 | } 416 | 417 | // Connection failure should retry, so it should take more than immediate 418 | if elapsed < 100*time.Millisecond { 419 | t.Error("Failed too quickly, connection retry might not be working") 420 | } 421 | 422 | // Should complete within reasonable time (not stuck in recursion) 423 | // With 2 retries and 1 second timeout, should be done in under 10 seconds 424 | if elapsed > 10*time.Second { 425 | t.Error("Took too long, might be stuck in recursion") 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "mime" 7 | "reflect" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | retry "github.com/StirlingMarketingGroup/go-retry" 13 | "github.com/davecgh/go-spew/spew" 14 | humanize "github.com/dustin/go-humanize" 15 | "github.com/jhillyerd/enmime" 16 | "golang.org/x/net/html/charset" 17 | ) 18 | 19 | // EmailAddresses represents a map of email addresses to display names 20 | type EmailAddresses map[string]string 21 | 22 | // Email represents an IMAP email message 23 | type Email struct { 24 | Flags []string 25 | Received time.Time 26 | Sent time.Time 27 | Size uint64 28 | Subject string 29 | UID int 30 | MessageID string 31 | From EmailAddresses 32 | To EmailAddresses 33 | ReplyTo EmailAddresses 34 | CC EmailAddresses 35 | BCC EmailAddresses 36 | Text string 37 | HTML string 38 | Attachments []Attachment 39 | } 40 | 41 | // Attachment represents an email attachment 42 | type Attachment struct { 43 | Name string 44 | MimeType string 45 | Content []byte 46 | } 47 | 48 | // Email parsing constants 49 | const ( 50 | EDate uint8 = iota 51 | ESubject 52 | EFrom 53 | ESender 54 | EReplyTo 55 | ETo 56 | ECC 57 | EBCC 58 | EInReplyTo 59 | EMessageID 60 | ) 61 | 62 | const ( 63 | EEName uint8 = iota 64 | EESR 65 | EEMailbox 66 | EEHost 67 | ) 68 | 69 | // String returns a formatted string representation of EmailAddresses 70 | func (e EmailAddresses) String() string { 71 | emails := strings.Builder{} 72 | i := 0 73 | for e, n := range e { 74 | if i != 0 { 75 | emails.WriteString(", ") 76 | } 77 | if len(n) != 0 { 78 | if strings.ContainsRune(n, ',') { 79 | emails.WriteString(fmt.Sprintf(`"%s" <%s>`, AddSlashes.Replace(n), e)) 80 | } else { 81 | emails.WriteString(fmt.Sprintf(`%s <%s>`, n, e)) 82 | } 83 | } else { 84 | emails.WriteString(e) 85 | } 86 | i++ 87 | } 88 | return emails.String() 89 | } 90 | 91 | // String returns a formatted string representation of an Email 92 | func (e Email) String() string { 93 | email := strings.Builder{} 94 | 95 | email.WriteString(fmt.Sprintf("Subject: %s\n", e.Subject)) 96 | 97 | if len(e.To) != 0 { 98 | email.WriteString(fmt.Sprintf("To: %s\n", e.To)) 99 | } 100 | if len(e.From) != 0 { 101 | email.WriteString(fmt.Sprintf("From: %s\n", e.From)) 102 | } 103 | if len(e.CC) != 0 { 104 | email.WriteString(fmt.Sprintf("CC: %s\n", e.CC)) 105 | } 106 | if len(e.BCC) != 0 { 107 | email.WriteString(fmt.Sprintf("BCC: %s\n", e.BCC)) 108 | } 109 | if len(e.ReplyTo) != 0 { 110 | email.WriteString(fmt.Sprintf("ReplyTo: %s\n", e.ReplyTo)) 111 | } 112 | if len(e.Text) != 0 { 113 | if len(e.Text) > 20 { 114 | email.WriteString(fmt.Sprintf("Text: %s...", e.Text[:20])) 115 | } else { 116 | email.WriteString(fmt.Sprintf("Text: %s", e.Text)) 117 | } 118 | email.WriteString(fmt.Sprintf("(%s)\n", humanize.Bytes(uint64(len(e.Text))))) 119 | } 120 | if len(e.HTML) != 0 { 121 | if len(e.HTML) > 20 { 122 | email.WriteString(fmt.Sprintf("HTML: %s...", e.HTML[:20])) 123 | } else { 124 | email.WriteString(fmt.Sprintf("HTML: %s", e.HTML)) 125 | } 126 | email.WriteString(fmt.Sprintf(" (%s)\n", humanize.Bytes(uint64(len(e.HTML))))) 127 | } 128 | 129 | if len(e.Attachments) != 0 { 130 | email.WriteString(fmt.Sprintf("%d Attachment(s): %s\n", len(e.Attachments), e.Attachments)) 131 | } 132 | 133 | return email.String() 134 | } 135 | 136 | // String returns a formatted string representation of an Attachment 137 | func (a Attachment) String() string { 138 | return fmt.Sprintf("%s (%s %s)", a.Name, a.MimeType, humanize.Bytes(uint64(len(a.Content)))) 139 | } 140 | 141 | // GetUIDs retrieves message UIDs matching a search criteria 142 | func (d *Dialer) GetUIDs(search string) (uids []int, err error) { 143 | r, err := d.Exec(`UID SEARCH `+search, true, RetryCount, nil) 144 | if err != nil { 145 | return nil, err 146 | } 147 | return parseUIDSearchResponse(r) 148 | } 149 | 150 | // MoveEmail moves an email to a different folder 151 | func (d *Dialer) MoveEmail(uid int, folder string) (err error) { 152 | // if we are currently read-only, switch to SELECT for the move-operation 153 | readOnlyState := d.ReadOnly 154 | if readOnlyState { 155 | _ = d.SelectFolder(d.Folder) 156 | } 157 | _, err = d.Exec(`UID MOVE `+strconv.Itoa(uid)+` "`+AddSlashes.Replace(folder)+`"`, true, RetryCount, nil) 158 | if readOnlyState { 159 | _ = d.ExamineFolder(d.Folder) 160 | } 161 | if err != nil { 162 | return err 163 | } 164 | d.Folder = folder 165 | return nil 166 | } 167 | 168 | // MarkSeen marks an email as seen/read 169 | func (d *Dialer) MarkSeen(uid int) (err error) { 170 | flags := Flags{ 171 | Seen: FlagAdd, 172 | } 173 | 174 | readOnlyState := d.ReadOnly 175 | if readOnlyState { 176 | _ = d.SelectFolder(d.Folder) 177 | } 178 | err = d.SetFlags(uid, flags) 179 | if readOnlyState { 180 | _ = d.ExamineFolder(d.Folder) 181 | } 182 | 183 | return err 184 | } 185 | 186 | // DeleteEmail marks an email for deletion 187 | func (d *Dialer) DeleteEmail(uid int) (err error) { 188 | flags := Flags{ 189 | Deleted: FlagAdd, 190 | } 191 | 192 | readOnlyState := d.ReadOnly 193 | if readOnlyState { 194 | if err = d.SelectFolder(d.Folder); err != nil { 195 | return err 196 | } 197 | } 198 | err = d.SetFlags(uid, flags) 199 | if readOnlyState { 200 | if e := d.ExamineFolder(d.Folder); e != nil && err == nil { 201 | err = e 202 | } 203 | } 204 | 205 | return err 206 | } 207 | 208 | // Expunge permanently removes emails marked for deletion 209 | func (d *Dialer) Expunge() (err error) { 210 | readOnlyState := d.ReadOnly 211 | if readOnlyState { 212 | if err = d.SelectFolder(d.Folder); err != nil { 213 | return err 214 | } 215 | } 216 | _, err = d.Exec("EXPUNGE", false, RetryCount, nil) 217 | if readOnlyState { 218 | if e := d.ExamineFolder(d.Folder); e != nil && err == nil { 219 | err = e 220 | } 221 | } 222 | return err 223 | } 224 | 225 | // SetFlags sets message flags (seen, deleted, etc.) 226 | func (d *Dialer) SetFlags(uid int, flags Flags) (err error) { 227 | // craft the flags-string 228 | addFlags := []string{} 229 | removeFlags := []string{} 230 | 231 | v := reflect.ValueOf(flags) 232 | t := reflect.TypeOf(flags) 233 | 234 | for i := 0; i < t.NumField(); i++ { 235 | field := t.Field(i) 236 | value := v.Field(i) 237 | 238 | if field.Type == reflect.TypeOf(FlagUnset) { 239 | switch FlagSet(value.Int()) { 240 | case FlagAdd: 241 | addFlags = append(addFlags, `\`+field.Name) 242 | case FlagRemove: 243 | removeFlags = append(removeFlags, `\`+field.Name) 244 | } 245 | } 246 | } 247 | 248 | // iterate over the keyword-map and add those too to the slices 249 | for keyword, state := range flags.Keywords { 250 | if state { 251 | addFlags = append(addFlags, keyword) 252 | } else { 253 | removeFlags = append(removeFlags, keyword) 254 | } 255 | } 256 | 257 | query := fmt.Sprintf("UID STORE %d", uid) 258 | if len(addFlags) > 0 { 259 | query += fmt.Sprintf(` +FLAGS (%s)`, strings.Join(addFlags, " ")) 260 | } 261 | if len(removeFlags) > 0 { 262 | query += fmt.Sprintf(` -FLAGS (%s)`, strings.Join(removeFlags, " ")) 263 | } 264 | 265 | // if we are currently read-only, switch to SELECT for the move-operation 266 | readOnlyState := d.ReadOnly 267 | if readOnlyState { 268 | _ = d.SelectFolder(d.Folder) 269 | } 270 | _, err = d.Exec(query, true, RetryCount, nil) 271 | if readOnlyState { 272 | _ = d.ExamineFolder(d.Folder) 273 | } 274 | 275 | return err 276 | } 277 | 278 | // GetEmails retrieves full email messages including body content 279 | func (d *Dialer) GetEmails(uids ...int) (emails map[int]*Email, err error) { 280 | emails, err = d.GetOverviews(uids...) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | if len(emails) == 0 { 286 | return emails, err 287 | } 288 | 289 | uidsStr := strings.Builder{} 290 | if len(uids) == 0 { 291 | uidsStr.WriteString("1:*") 292 | } else { 293 | i := 0 294 | for u := range emails { 295 | if u == 0 { 296 | continue 297 | } 298 | 299 | if i != 0 { 300 | uidsStr.WriteByte(',') 301 | } 302 | uidsStr.WriteString(strconv.Itoa(u)) 303 | i++ 304 | } 305 | } 306 | 307 | var records [][]*Token 308 | err = retry.Retry(func() (err error) { 309 | r, err := d.Exec("UID FETCH "+uidsStr.String()+" BODY.PEEK[]", true, 0, nil) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | records, err = d.ParseFetchResponse(r) 315 | if err != nil { 316 | return err 317 | } 318 | 319 | for _, tks := range records { 320 | // Some servers may wrap the FETCH content with extra parentheses. 321 | // Flatten single-child containers defensively until we reach fields. 322 | for len(tks) == 1 && tks[0].Type == TContainer { 323 | tks = tks[0].Tokens 324 | } 325 | e := &Email{} 326 | skip := 0 327 | success := true 328 | for i, t := range tks { 329 | if skip > 0 { 330 | skip-- 331 | continue 332 | } 333 | if err = d.CheckType(t, []TType{TLiteral}, tks, "in root"); err != nil { 334 | return err 335 | } 336 | switch t.Str { 337 | case "BODY[]": 338 | if err = d.CheckType(tks[i+1], []TType{TAtom}, tks, "after BODY[]"); err != nil { 339 | return err 340 | } 341 | msg := tks[i+1].Str 342 | r := strings.NewReader(msg) 343 | 344 | env, err := enmime.ReadEnvelope(r) 345 | if err != nil { 346 | if Verbose { 347 | warnLog(d.ConnNum, d.Folder, "email body could not be parsed", "error", err) 348 | spew.Dump(env) 349 | spew.Dump(msg) 350 | } 351 | success = false 352 | } else { 353 | e.Subject = env.GetHeader("Subject") 354 | e.Text = env.Text 355 | e.HTML = env.HTML 356 | 357 | if len(env.Attachments) != 0 { 358 | for _, a := range env.Attachments { 359 | e.Attachments = append(e.Attachments, Attachment{ 360 | Name: a.FileName, 361 | MimeType: a.ContentType, 362 | Content: a.Content, 363 | }) 364 | } 365 | } 366 | 367 | if len(env.Inlines) != 0 { 368 | for _, a := range env.Inlines { 369 | e.Attachments = append(e.Attachments, Attachment{ 370 | Name: a.FileName, 371 | MimeType: a.ContentType, 372 | Content: a.Content, 373 | }) 374 | } 375 | } 376 | 377 | for _, a := range []struct { 378 | dest *EmailAddresses 379 | header string 380 | }{ 381 | {&e.From, "From"}, 382 | {&e.ReplyTo, "Reply-To"}, 383 | {&e.To, "To"}, 384 | {&e.CC, "cc"}, 385 | {&e.BCC, "bcc"}, 386 | } { 387 | alist, _ := env.AddressList(a.header) 388 | (*a.dest) = make(map[string]string, len(alist)) 389 | for _, addr := range alist { 390 | (*a.dest)[strings.ToLower(addr.Address)] = addr.Name 391 | } 392 | } 393 | } 394 | skip++ 395 | case "UID": 396 | if err = d.CheckType(tks[i+1], []TType{TNumber}, tks, "after UID"); err != nil { 397 | return err 398 | } 399 | e.UID = tks[i+1].Num 400 | skip++ 401 | } 402 | } 403 | 404 | if success { 405 | if emails[e.UID] == nil { 406 | emails[e.UID] = &Email{UID: e.UID} 407 | } 408 | emails[e.UID].Subject = e.Subject 409 | emails[e.UID].From = e.From 410 | emails[e.UID].ReplyTo = e.ReplyTo 411 | emails[e.UID].To = e.To 412 | emails[e.UID].CC = e.CC 413 | emails[e.UID].BCC = e.BCC 414 | emails[e.UID].Text = e.Text 415 | emails[e.UID].HTML = e.HTML 416 | emails[e.UID].Attachments = e.Attachments 417 | } else { 418 | delete(emails, e.UID) 419 | } 420 | } 421 | return err 422 | }, RetryCount, func(err error) error { 423 | errorLog(d.ConnNum, d.Folder, "fetch failed", "error", err) 424 | _ = d.Close() 425 | return nil 426 | }, func() error { 427 | return d.Reconnect() 428 | }) 429 | 430 | return emails, err 431 | } 432 | 433 | // GetOverviews retrieves email overview information (headers, flags, etc.) 434 | func (d *Dialer) GetOverviews(uids ...int) (emails map[int]*Email, err error) { 435 | uidsStr := strings.Builder{} 436 | if len(uids) == 0 { 437 | uidsStr.WriteString("1:*") 438 | } else { 439 | for i, u := range uids { 440 | if u == 0 { 441 | continue 442 | } 443 | 444 | if i != 0 { 445 | uidsStr.WriteByte(',') 446 | } 447 | uidsStr.WriteString(strconv.Itoa(u)) 448 | } 449 | } 450 | 451 | var records [][]*Token 452 | err = retry.Retry(func() (err error) { 453 | r, err := d.Exec("UID FETCH "+uidsStr.String()+" ALL", true, 0, nil) 454 | if err != nil { 455 | return err 456 | } 457 | 458 | if len(r) == 0 { 459 | return err 460 | } 461 | 462 | records, err = d.ParseFetchResponse(r) 463 | if err != nil { 464 | return err 465 | } 466 | return err 467 | }, RetryCount, func(err error) error { 468 | errorLog(d.ConnNum, d.Folder, "fetch failed", "error", err) 469 | _ = d.Close() 470 | return nil 471 | }, func() error { 472 | return d.Reconnect() 473 | }) 474 | if err != nil { 475 | return nil, err 476 | } 477 | 478 | emails = make(map[int]*Email, len(uids)) 479 | 480 | for _, tks := range records { 481 | for len(tks) == 1 && tks[0].Type == TContainer { 482 | tks = tks[0].Tokens 483 | } 484 | e := &Email{} 485 | skip := 0 486 | for i, t := range tks { 487 | if skip > 0 { 488 | skip-- 489 | continue 490 | } 491 | if err = d.CheckType(t, []TType{TLiteral}, tks, "in root"); err != nil { 492 | return nil, err 493 | } 494 | switch t.Str { 495 | case "FLAGS": 496 | if err = d.CheckType(tks[i+1], []TType{TContainer}, tks, "after FLAGS"); err != nil { 497 | return nil, err 498 | } 499 | e.Flags = make([]string, len(tks[i+1].Tokens)) 500 | for i, t := range tks[i+1].Tokens { 501 | if err = d.CheckType(t, []TType{TLiteral}, tks, "for FLAGS[%d]", i); err != nil { 502 | return nil, err 503 | } 504 | e.Flags[i] = t.Str 505 | } 506 | skip++ 507 | case "INTERNALDATE": 508 | if err = d.CheckType(tks[i+1], []TType{TQuoted}, tks, "after INTERNALDATE"); err != nil { 509 | return nil, err 510 | } 511 | e.Received, err = time.Parse(TimeFormat, tks[i+1].Str) 512 | if err != nil { 513 | return nil, err 514 | } 515 | e.Received = e.Received.UTC() 516 | skip++ 517 | case "RFC822.SIZE": 518 | if err = d.CheckType(tks[i+1], []TType{TNumber}, tks, "after RFC822.SIZE"); err != nil { 519 | return nil, err 520 | } 521 | e.Size = uint64(tks[i+1].Num) 522 | skip++ 523 | case "ENVELOPE": 524 | CharsetReader := func(label string, input io.Reader) (io.Reader, error) { 525 | label = strings.ReplaceAll(label, "windows-", "cp") 526 | encoding, _ := charset.Lookup(label) 527 | return encoding.NewDecoder().Reader(input), nil 528 | } 529 | dec := mime.WordDecoder{CharsetReader: CharsetReader} 530 | 531 | if err = d.CheckType(tks[i+1], []TType{TContainer}, tks, "after ENVELOPE"); err != nil { 532 | return nil, err 533 | } 534 | if err = d.CheckType(tks[i+1].Tokens[EDate], []TType{TQuoted, TNil}, tks, "for ENVELOPE[%d]", EDate); err != nil { 535 | return nil, err 536 | } 537 | if err = d.CheckType(tks[i+1].Tokens[ESubject], []TType{TQuoted, TAtom, TNil}, tks, "for ENVELOPE[%d]", ESubject); err != nil { 538 | return nil, err 539 | } 540 | 541 | e.Sent, _ = time.Parse("Mon, _2 Jan 2006 15:04:05 -0700", tks[i+1].Tokens[EDate].Str) 542 | e.Sent = e.Sent.UTC() 543 | 544 | e.Subject, err = dec.DecodeHeader(tks[i+1].Tokens[ESubject].Str) 545 | if err != nil { 546 | return nil, err 547 | } 548 | 549 | for _, a := range []struct { 550 | dest *EmailAddresses 551 | pos uint8 552 | debug string 553 | }{ 554 | {&e.From, EFrom, "FROM"}, 555 | {&e.ReplyTo, EReplyTo, "REPLYTO"}, 556 | {&e.To, ETo, "TO"}, 557 | {&e.CC, ECC, "CC"}, 558 | {&e.BCC, EBCC, "BCC"}, 559 | } { 560 | if tks[i+1].Tokens[a.pos].Type != TNil { 561 | if err = d.CheckType(tks[i+1].Tokens[a.pos], []TType{TNil, TContainer}, tks, "for ENVELOPE[%d]", a.pos); err != nil { 562 | return nil, err 563 | } 564 | *a.dest = make(map[string]string, len(tks[i+1].Tokens[a.pos].Tokens)) 565 | for i, t := range tks[i+1].Tokens[a.pos].Tokens { 566 | if err = d.CheckType(t.Tokens[EEName], []TType{TQuoted, TAtom, TNil}, tks, "for %s[%d][%d]", a.debug, i, EEName); err != nil { 567 | return nil, err 568 | } 569 | if err = d.CheckType(t.Tokens[EEMailbox], []TType{TQuoted, TAtom, TNil}, tks, "for %s[%d][%d]", a.debug, i, EEMailbox); err != nil { 570 | return nil, err 571 | } 572 | if err = d.CheckType(t.Tokens[EEHost], []TType{TQuoted, TAtom, TNil}, tks, "for %s[%d][%d]", a.debug, i, EEHost); err != nil { 573 | return nil, err 574 | } 575 | 576 | name, err := dec.DecodeHeader(t.Tokens[EEName].Str) 577 | if err != nil { 578 | return nil, err 579 | } 580 | 581 | mailbox, err := dec.DecodeHeader(t.Tokens[EEMailbox].Str) 582 | if err != nil { 583 | return nil, err 584 | } 585 | 586 | host, err := dec.DecodeHeader(t.Tokens[EEHost].Str) 587 | if err != nil { 588 | return nil, err 589 | } 590 | 591 | (*a.dest)[strings.ToLower(mailbox+"@"+host)] = name 592 | } 593 | } 594 | } 595 | 596 | e.MessageID = tks[i+1].Tokens[EMessageID].Str 597 | 598 | skip++ 599 | case "UID": 600 | if err = d.CheckType(tks[i+1], []TType{TNumber}, tks, "after UID"); err != nil { 601 | return nil, err 602 | } 603 | e.UID = tks[i+1].Num 604 | skip++ 605 | } 606 | } 607 | 608 | if e.UID > 0 { 609 | emails[e.UID] = e 610 | } 611 | } 612 | 613 | return emails, nil 614 | } 615 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go IMAP Client (go-imap) 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/BrianLeishman/go-imap.svg)](https://pkg.go.dev/github.com/BrianLeishman/go-imap) 4 | [![CI](https://github.com/BrianLeishman/go-imap/actions/workflows/go.yml/badge.svg)](https://github.com/BrianLeishman/go-imap/actions/workflows/go.yml) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/BrianLeishman/go-imap)](https://goreportcard.com/report/github.com/BrianLeishman/go-imap) 6 | 7 | Simple, pragmatic IMAP client for Go (Golang) with TLS, LOGIN or XOAUTH2 (OAuth 2.0), IDLE notifications, robust reconnects, and batteries‑included helpers for searching, fetching, moving, and flagging messages. 8 | 9 | Works great with Gmail, Office 365/Exchange, and most RFC‑compliant IMAP servers. 10 | 11 | ## Features 12 | 13 | - TLS connections and timeouts (`DialTimeout`, `CommandTimeout`) 14 | - Authentication via `LOGIN` and `XOAUTH2` 15 | - Folders: `SELECT`/`EXAMINE`, list folders, error-tolerant counting 16 | - Search: `UID SEARCH` helpers with RFC 3501 literal syntax for non-ASCII text 17 | - Fetch: envelope, flags, size, text/HTML bodies, attachments 18 | - Mutations: move, set flags, delete + expunge 19 | - IMAP IDLE with event handlers for `EXISTS`, `EXPUNGE`, `FETCH` 20 | - Automatic reconnect with re‑auth and folder restore 21 | - Robust folder handling with graceful error recovery for problematic folders 22 | 23 | ## Install 24 | 25 | ```bash 26 | go get github.com/BrianLeishman/go-imap 27 | ``` 28 | 29 | Requires Go 1.25+ (see `go.mod`). 30 | 31 | ## Quick Start 32 | 33 | ### Basic Connection (LOGIN) 34 | 35 | ```go 36 | package main 37 | 38 | import ( 39 | "fmt" 40 | "time" 41 | imap "github.com/BrianLeishman/go-imap" 42 | ) 43 | 44 | func main() { 45 | // Optional configuration 46 | imap.Verbose = false // Enable to emit debug-level IMAP logs 47 | imap.RetryCount = 3 // Number of retries for failed commands 48 | imap.DialTimeout = 10 * time.Second 49 | imap.CommandTimeout = 30 * time.Second 50 | 51 | // For self-signed certificates (use with caution!) 52 | // imap.TLSSkipVerify = true 53 | 54 | // Connect with standard LOGIN authentication 55 | m, err := imap.New("username", "password", "mail.server.com", 993) 56 | if err != nil { panic(err) } 57 | defer m.Close() 58 | 59 | // Quick test 60 | folders, err := m.GetFolders() 61 | if err != nil { panic(err) } 62 | fmt.Printf("Connected! Found %d folders\n", len(folders)) 63 | } 64 | ``` 65 | 66 | ### OAuth 2.0 Authentication (XOAUTH2) 67 | 68 | ```go 69 | // Connect with OAuth2 (Gmail, Office 365, etc.) 70 | m, err := imap.NewWithOAuth2("user@example.com", accessToken, "imap.gmail.com", 993) 71 | if err != nil { panic(err) } 72 | defer m.Close() 73 | 74 | // The OAuth2 connection works exactly like LOGIN after authentication 75 | if err := m.SelectFolder("INBOX"); err != nil { panic(err) } 76 | ``` 77 | 78 | ## Logging 79 | 80 | The client uses Go's `log/slog` package for structured logging. By default it 81 | emits info, warning, and error events to standard error with the `component` 82 | attribute set to `imap/agent`. Opt-in debug output is controlled by the 83 | existing `imap.Verbose` flag: 84 | 85 | ```go 86 | imap.Verbose = true // Log every IMAP command/response at debug level 87 | ``` 88 | 89 | You can plug in your own logger implementation via `imap.SetLogger`. For 90 | `*slog.Logger` specifically, call `imap.SetSlogLogger`. When unset, the library 91 | falls back to a text handler. 92 | 93 | ```go 94 | import ( 95 | "log/slog" 96 | "os" 97 | imap "github.com/BrianLeishman/go-imap" 98 | ) 99 | 100 | handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) 101 | imap.SetSlogLogger(slog.New(handler)) 102 | ``` 103 | 104 | Call `imap.SetLogger(nil)` to reset to the built-in logger. When verbose mode is 105 | enabled you can further reduce noise by setting `imap.SkipResponses = true` to 106 | suppress raw server responses. 107 | 108 | ## Examples 109 | 110 | Complete, runnable example programs are available in the [`examples/`](examples/) directory. Each example demonstrates specific features and can be run directly: 111 | 112 | ```bash 113 | go run examples/basic_connection/main.go 114 | ``` 115 | 116 | ### Available Examples 117 | 118 | #### Getting Started 119 | 120 | - [`basic_connection`](examples/basic_connection/main.go) - Basic LOGIN authentication and connection setup 121 | - [`oauth2_connection`](examples/oauth2_connection/main.go) - OAuth 2.0 (XOAUTH2) authentication for Gmail/Office 365 122 | 123 | #### Working with Emails 124 | 125 | - [`folders`](examples/folders/main.go) - List folders, select/examine folders, get email counts 126 | - [`search`](examples/search/main.go) - Search emails by various criteria (flags, dates, sender, size, etc.) 127 | - [`literal_search`](examples/literal_search/main.go) - Search with non-ASCII characters using RFC 3501 literal syntax 128 | - [`fetch_emails`](examples/fetch_emails/main.go) - Fetch email headers (fast) and full content with attachments (slower) 129 | - [`email_operations`](examples/email_operations/main.go) - Move emails, set/remove flags, delete and expunge 130 | 131 | #### Advanced Features 132 | 133 | - [`idle_monitoring`](examples/idle_monitoring/main.go) - Real-time email notifications with IDLE 134 | - [`error_handling`](examples/error_handling/main.go) - Robust error handling, reconnection, and timeout configuration 135 | - [`complete_example`](examples/complete_example/main.go) - Full-featured example combining multiple operations 136 | 137 | ## Detailed Usage Examples 138 | 139 | ### 1. Working with Folders 140 | 141 | ```go 142 | // List all folders 143 | folders, err := m.GetFolders() 144 | if err != nil { panic(err) } 145 | 146 | // Example output: 147 | // folders = []string{ 148 | // "INBOX", 149 | // "Sent", 150 | // "Drafts", 151 | // "Trash", 152 | // "INBOX/Receipts", 153 | // "INBOX/Important", 154 | // "[Gmail]/All Mail", 155 | // "[Gmail]/Spam", 156 | // } 157 | 158 | for _, folder := range folders { 159 | fmt.Println("Folder:", folder) 160 | } 161 | 162 | // Select a folder for operations (read-write mode) 163 | err = m.SelectFolder("INBOX") 164 | if err != nil { panic(err) } 165 | 166 | // Select folder in read-only mode 167 | err = m.ExamineFolder("INBOX") 168 | if err != nil { panic(err) } 169 | 170 | // Get total email count across all folders 171 | totalCount, err := m.GetTotalEmailCount() 172 | if err != nil { panic(err) } 173 | fmt.Printf("Total emails in all folders: %d\n", totalCount) 174 | 175 | // Get count excluding certain folders 176 | excludedFolders := []string{"Trash", "[Gmail]/Spam"} 177 | count, err := m.GetTotalEmailCountExcluding(excludedFolders) 178 | if err != nil { panic(err) } 179 | fmt.Printf("Total emails (excluding spam/trash): %d\n", count) 180 | 181 | // Error-tolerant counting (continues even if some folders fail) 182 | // This is especially useful with Gmail or other providers that have inaccessible system folders 183 | safeCount, folderErrors, err := m.GetTotalEmailCountSafe() 184 | if err != nil { panic(err) } 185 | fmt.Printf("Total accessible emails: %d\n", safeCount) 186 | 187 | if len(folderErrors) > 0 { 188 | fmt.Printf("Note: %d folders had errors:\n", len(folderErrors)) 189 | for _, folderErr := range folderErrors { 190 | fmt.Printf(" - %v\n", folderErr) 191 | } 192 | } 193 | // Example output: 194 | // Total accessible emails: 1247 195 | // Note: 2 folders had errors: 196 | // - folder "[Gmail]": NO [NONEXISTENT] Unknown Mailbox 197 | // - folder "[Gmail]/All Mail": NO [NONEXISTENT] Unknown Mailbox 198 | 199 | // Get detailed statistics for each folder (includes max UID) 200 | stats, err := m.GetFolderStats() 201 | if err != nil { panic(err) } 202 | 203 | fmt.Printf("Found %d folders:\n", len(stats)) 204 | for _, stat := range stats { 205 | if stat.Error != nil { 206 | fmt.Printf(" %-20s [ERROR]: %v\n", stat.Name, stat.Error) 207 | } else { 208 | fmt.Printf(" %-20s %5d emails, max UID: %d\n", 209 | stat.Name, stat.Count, stat.MaxUID) 210 | } 211 | } 212 | // Example output: 213 | // Found 8 folders: 214 | // INBOX 342 emails, max UID: 1543 215 | // Sent 89 emails, max UID: 234 216 | // Drafts 3 emails, max UID: 67 217 | // Trash 12 emails, max UID: 89 218 | // [Gmail] [ERROR]: NO [NONEXISTENT] Unknown Mailbox 219 | // [Gmail]/Spam 0 emails, max UID: 0 220 | // INBOX/Archive 801 emails, max UID: 2156 221 | // INBOX/Important 45 emails, max UID: 987 222 | ``` 223 | 224 | ### 1.1. Handling Problematic Folders 225 | 226 | Some IMAP servers (especially Gmail) have special system folders that cannot be examined or may return errors. The traditional `GetTotalEmailCount()` method will fail completely if any folder is inaccessible, but the new safe methods continue processing other folders. 227 | 228 | #### When to Use Safe Methods 229 | 230 | - **Gmail users**: Gmail's `[Gmail]` folder often returns "NO [NONEXISTENT] Unknown Mailbox" 231 | - **Exchange/Office 365**: Some system folders may be restricted 232 | - **Custom IMAP servers**: Servers with permission-restricted folders 233 | - **Production applications**: When you need reliable email counting despite folder issues 234 | 235 | ```go 236 | // Traditional approach - fails if ANY folder has issues 237 | totalCount, err := m.GetTotalEmailCount() 238 | if err != nil { 239 | // This will fail completely if "[Gmail]" folder is inaccessible 240 | fmt.Printf("Count failed: %v\n", err) 241 | // Output: Count failed: EXAMINE command failed: NO [NONEXISTENT] Unknown Mailbox 242 | } 243 | 244 | // Safe approach - continues despite folder errors 245 | safeCount, folderErrors, err := m.GetTotalEmailCountSafe() 246 | if err != nil { 247 | // Only fails on serious connection issues, not individual folder problems 248 | panic(err) 249 | } 250 | 251 | fmt.Printf("Counted %d emails from accessible folders\n", safeCount) 252 | if len(folderErrors) > 0 { 253 | fmt.Printf("Skipped %d problematic folders\n", len(folderErrors)) 254 | } 255 | 256 | // Safe exclusion - combine error tolerance with folder filtering 257 | excludedFolders := []string{"Trash", "Junk", "Deleted Items"} 258 | count, folderErrors, err := m.GetTotalEmailCountSafeExcluding(excludedFolders) 259 | if err != nil { panic(err) } 260 | 261 | fmt.Printf("Active emails: %d (excluding trash/spam and skipping errors)\n", count) 262 | 263 | // Detailed analysis with error handling 264 | stats, err := m.GetFolderStats() 265 | if err != nil { panic(err) } 266 | 267 | accessibleFolders := 0 268 | totalEmails := 0 269 | maxUID := 0 270 | 271 | for _, stat := range stats { 272 | if stat.Error != nil { 273 | fmt.Printf("⚠️ %s: %v\n", stat.Name, stat.Error) 274 | continue 275 | } 276 | 277 | accessibleFolders++ 278 | totalEmails += stat.Count 279 | if stat.MaxUID > maxUID { 280 | maxUID = stat.MaxUID 281 | } 282 | 283 | fmt.Printf("✅ %-25s %5d emails (UID range: 1-%d)\n", 284 | stat.Name, stat.Count, stat.MaxUID) 285 | } 286 | 287 | fmt.Printf("\nSummary: %d/%d folders accessible, %d total emails, highest UID: %d\n", 288 | accessibleFolders, len(stats), totalEmails, maxUID) 289 | ``` 290 | 291 | #### Error Types You Might Encounter 292 | 293 | ```go 294 | stats, err := m.GetFolderStats() 295 | if err != nil { panic(err) } 296 | 297 | for _, stat := range stats { 298 | if stat.Error != nil { 299 | fmt.Printf("Folder '%s' error: %v\n", stat.Name, stat.Error) 300 | 301 | // Common error patterns: 302 | if strings.Contains(stat.Error.Error(), "NONEXISTENT") { 303 | fmt.Printf(" → This is a virtual/system folder that can't be examined\n") 304 | } else if strings.Contains(stat.Error.Error(), "permission") { 305 | fmt.Printf(" → This folder requires special permissions\n") 306 | } else { 307 | fmt.Printf(" → Unexpected error, might indicate connection issues\n") 308 | } 309 | } 310 | } 311 | ``` 312 | 313 | ### 2. Searching for Emails 314 | 315 | ```go 316 | // Select folder first 317 | err := m.SelectFolder("INBOX") 318 | if err != nil { panic(err) } 319 | 320 | // Basic searches - returns slice of UIDs 321 | allUIDs, _ := m.GetUIDs("ALL") // All emails 322 | unseenUIDs, _ := m.GetUIDs("UNSEEN") // Unread emails 323 | recentUIDs, _ := m.GetUIDs("RECENT") // Recent emails 324 | seenUIDs, _ := m.GetUIDs("SEEN") // Read emails 325 | flaggedUIDs, _ := m.GetUIDs("FLAGGED") // Starred/flagged emails 326 | 327 | // Example output: 328 | fmt.Printf("Found %d total emails\n", len(allUIDs)) // Found 342 total emails 329 | fmt.Printf("Found %d unread emails\n", len(unseenUIDs)) // Found 12 unread emails 330 | fmt.Printf("UIDs of unread: %v\n", unseenUIDs) // UIDs of unread: [245 246 247 251 252 253 254 255 256 257 258 259] 331 | 332 | // Date-based searches 333 | todayUIDs, _ := m.GetUIDs("ON 15-Sep-2024") 334 | sinceUIDs, _ := m.GetUIDs("SINCE 10-Sep-2024") 335 | beforeUIDs, _ := m.GetUIDs("BEFORE 20-Sep-2024") 336 | rangeUIDs, _ := m.GetUIDs("SINCE 1-Sep-2024 BEFORE 30-Sep-2024") 337 | 338 | // From/To searches 339 | fromBossUIDs, _ := m.GetUIDs(`FROM "boss@company.com"`) 340 | toMeUIDs, _ := m.GetUIDs(`TO "me@company.com"`) 341 | 342 | // Subject/body searches 343 | subjectUIDs, _ := m.GetUIDs(`SUBJECT "invoice"`) 344 | bodyUIDs, _ := m.GetUIDs(`BODY "payment"`) 345 | textUIDs, _ := m.GetUIDs(`TEXT "urgent"`) // Searches both subject and body 346 | 347 | // Complex searches 348 | complexUIDs, _ := m.GetUIDs(`UNSEEN FROM "support@github.com" SINCE 1-Sep-2024`) 349 | 350 | // UID ranges 351 | firstUID, _ := m.GetUIDs("1") // First email 352 | lastUID, _ := m.GetUIDs("*") // Last email 353 | rangeUIDs, _ := m.GetUIDs("1:10") // First 10 emails 354 | last10UIDs, _ := m.GetUIDs("*:10") // Last 10 emails (reverse) 355 | 356 | // Size-based searches 357 | largeUIDs, _ := m.GetUIDs("LARGER 10485760") // Emails larger than 10MB 358 | smallUIDs, _ := m.GetUIDs("SMALLER 1024") // Emails smaller than 1KB 359 | 360 | // Non-ASCII searches using RFC 3501 literal syntax 361 | // The library automatically detects and handles literal syntax {n} 362 | // where n is the byte count of the following data 363 | 364 | // Search for Cyrillic text in subject (тест = 8 bytes in UTF-8) 365 | cyrillicUIDs, _ := m.GetUIDs("CHARSET UTF-8 Subject {8}\r\nтест") 366 | 367 | // Search for Chinese text in subject (测试 = 6 bytes in UTF-8) 368 | chineseUIDs, _ := m.GetUIDs("CHARSET UTF-8 Subject {6}\r\n测试") 369 | 370 | // Search for Japanese text in body (テスト = 9 bytes in UTF-8) 371 | japaneseUIDs, _ := m.GetUIDs("CHARSET UTF-8 BODY {9}\r\nテスト") 372 | 373 | // Search for Arabic text (اختبار = 12 bytes in UTF-8) 374 | arabicUIDs, _ := m.GetUIDs("CHARSET UTF-8 TEXT {12}\r\nاختبار") 375 | 376 | // Search with emoji (😀👍 = 8 bytes in UTF-8) 377 | emojiUIDs, _ := m.GetUIDs("CHARSET UTF-8 TEXT {8}\r\n😀👍") 378 | 379 | // Note: Always specify CHARSET UTF-8 for non-ASCII searches 380 | // The {n} syntax tells the server exactly how many bytes to expect 381 | // This is crucial since Unicode characters use multiple bytes 382 | ``` 383 | 384 | ### 3. Fetching Email Details 385 | 386 | ```go 387 | // Get overview (headers only, no body) - FAST 388 | overviews, err := m.GetOverviews(uids...) 389 | if err != nil { panic(err) } 390 | 391 | for uid, email := range overviews { 392 | fmt.Printf("UID %d:\n", uid) 393 | fmt.Printf(" Subject: %s\n", email.Subject) 394 | fmt.Printf(" From: %s\n", email.From) 395 | fmt.Printf(" Date: %s\n", email.Sent) 396 | fmt.Printf(" Size: %d bytes\n", email.Size) 397 | fmt.Printf(" Flags: %v\n", email.Flags) 398 | } 399 | 400 | // Example output: 401 | // UID 245: 402 | // Subject: Your order has shipped! 403 | // From: Amazon 404 | // Date: 2024-09-15 14:23:01 +0000 UTC 405 | // Size: 45234 bytes 406 | // Flags: [\Seen] 407 | 408 | // Get full emails with bodies - SLOWER 409 | emails, err := m.GetEmails(uids...) 410 | if err != nil { panic(err) } 411 | 412 | for uid, email := range emails { 413 | fmt.Printf("\n=== Email UID %d ===\n", uid) 414 | fmt.Printf("Subject: %s\n", email.Subject) 415 | fmt.Printf("From: %s\n", email.From) 416 | fmt.Printf("To: %s\n", email.To) 417 | fmt.Printf("CC: %s\n", email.CC) 418 | fmt.Printf("Date Sent: %s\n", email.Sent) 419 | fmt.Printf("Date Received: %s\n", email.Received) 420 | fmt.Printf("Message-ID: %s\n", email.MessageID) 421 | fmt.Printf("Flags: %v\n", email.Flags) 422 | fmt.Printf("Size: %d bytes\n", email.Size) 423 | 424 | // Body content 425 | if len(email.Text) > 0 { 426 | fmt.Printf("Text (first 200 chars): %.200s...\n", email.Text) 427 | } 428 | if len(email.HTML) > 0 { 429 | fmt.Printf("HTML length: %d bytes\n", len(email.HTML)) 430 | } 431 | 432 | // Attachments 433 | if len(email.Attachments) > 0 { 434 | fmt.Printf("Attachments (%d):\n", len(email.Attachments)) 435 | for _, att := range email.Attachments { 436 | fmt.Printf(" - %s (%s, %d bytes)\n", 437 | att.Name, att.MimeType, len(att.Content)) 438 | } 439 | } 440 | } 441 | 442 | // Example full output: 443 | // === Email UID 245 === 444 | // Subject: Your order has shipped! 445 | // From: ship-confirm@amazon.com:Amazon Shipping 446 | // To: customer@example.com:John Doe 447 | // CC: 448 | // Date Sent: 2024-09-15 14:23:01 +0000 UTC 449 | // Date Received: 2024-09-15 14:23:15 +0000 UTC 450 | // Message-ID: <20240915142301.3F4A5B0@amazon.com> 451 | // Flags: [\Seen] 452 | // Size: 45234 bytes 453 | // Text (first 200 chars): Hello John, Your order #123-4567890 has shipped and is on its way! Track your package: ... 454 | // HTML length: 42150 bytes 455 | // Attachments (2): 456 | // - invoice.pdf (application/pdf, 125432 bytes) 457 | // - shipping-label.png (image/png, 85234 bytes) 458 | 459 | // Using the String() method for a quick summary 460 | email := emails[245] 461 | fmt.Print(email) 462 | // Output: 463 | // Subject: Your order has shipped! 464 | // To: customer@example.com:John Doe 465 | // From: ship-confirm@amazon.com:Amazon Shipping 466 | // Text: Hello John, Your order...(4.5 kB) 467 | // HTML: 0 { 727 | fmt.Printf("\n📧 Fetching first %d unread emails...\n", limit) 728 | emails, err := m.GetEmails(unreadUIDs[:limit]...) 729 | if err != nil { 730 | log.Fatalf("Failed to fetch emails: %v", err) 731 | } 732 | 733 | for uid, email := range emails { 734 | fmt.Printf("\n--- Email UID %d ---\n", uid) 735 | fmt.Printf("From: %s\n", email.From) 736 | fmt.Printf("Subject: %s\n", email.Subject) 737 | fmt.Printf("Date: %s\n", email.Sent.Format("Jan 2, 2006 3:04 PM")) 738 | fmt.Printf("Size: %.1f KB\n", float64(email.Size)/1024) 739 | 740 | if len(email.Text) > 100 { 741 | fmt.Printf("Preview: %.100s...\n", email.Text) 742 | } else if len(email.Text) > 0 { 743 | fmt.Printf("Preview: %s\n", email.Text) 744 | } 745 | 746 | if len(email.Attachments) > 0 { 747 | fmt.Printf("Attachments: %d\n", len(email.Attachments)) 748 | for _, att := range email.Attachments { 749 | fmt.Printf(" - %s (%.1f KB)\n", att.Name, float64(len(att.Content))/1024) 750 | } 751 | } 752 | 753 | // Mark first email as read 754 | if uid == unreadUIDs[0] { 755 | fmt.Printf("\n✓ Marking email %d as read...\n", uid) 756 | if err := m.MarkSeen(uid); err != nil { 757 | fmt.Printf("Failed to mark as read: %v\n", err) 758 | } 759 | } 760 | } 761 | } 762 | 763 | // Get some statistics 764 | fmt.Println("\n📊 Mailbox Statistics:") 765 | allUIDs, _ := m.GetUIDs("ALL") 766 | seenUIDs, _ := m.GetUIDs("SEEN") 767 | flaggedUIDs, _ := m.GetUIDs("FLAGGED") 768 | 769 | fmt.Printf(" Total emails: %d\n", len(allUIDs)) 770 | fmt.Printf(" Read emails: %d\n", len(seenUIDs)) 771 | fmt.Printf(" Unread emails: %d\n", len(allUIDs)-len(seenUIDs)) 772 | fmt.Printf(" Flagged emails: %d\n", len(flaggedUIDs)) 773 | 774 | // Start IDLE monitoring for 10 seconds 775 | fmt.Println("\n👀 Monitoring for new emails (10 seconds)...") 776 | handler := &imap.IdleHandler{ 777 | OnExists: func(e imap.ExistsEvent) { 778 | fmt.Printf(" 📬 New email arrived! (message #%d)\n", e.MessageIndex) 779 | }, 780 | } 781 | 782 | if err := m.StartIdle(handler); err == nil { 783 | time.Sleep(10 * time.Second) 784 | _ = m.StopIdle() 785 | } 786 | 787 | fmt.Println("\n✅ Done!") 788 | } 789 | 790 | /* Example Output: 791 | 792 | Connecting to IMAP server... 793 | 794 | 📁 Available folders: 795 | - INBOX 796 | - Sent 797 | - Drafts 798 | - Trash 799 | - [Gmail]/All Mail 800 | - [Gmail]/Spam 801 | - [Gmail]/Starred 802 | - [Gmail]/Important 803 | 804 | 📥 Selecting INBOX... 805 | 806 | 🔍 Searching for unread emails... 807 | Found 3 unread emails 808 | 809 | 📧 Fetching first 3 unread emails... 810 | 811 | --- Email UID 1247 --- 812 | From: notifications@github.com:GitHub 813 | Subject: [org/repo] New issue: Bug in authentication flow (#123) 814 | Date: Nov 11, 2024 2:15 PM 815 | Size: 8.5 KB 816 | Preview: User johndoe opened an issue: When trying to authenticate with OAuth2, the system returns a 401 error even with valid... 817 | Attachments: 0 818 | 819 | ✓ Marking email 1247 as read... 820 | 821 | --- Email UID 1248 --- 822 | From: team@company.com:Team Update 823 | Subject: Weekly Team Sync - Meeting Notes 824 | Date: Nov 11, 2024 3:30 PM 825 | Size: 12.3 KB 826 | Preview: Hi team, Here are the notes from today's sync: 1. Project Alpha is on track for Dec release 2. Need volunteers for... 827 | Attachments: 1 828 | - meeting-notes.pdf (156.2 KB) 829 | 830 | --- Email UID 1249 --- 831 | From: noreply@service.com:Service Alert 832 | Subject: Your monthly report is ready 833 | Date: Nov 11, 2024 4:45 PM 834 | Size: 45.6 KB 835 | Preview: Your monthly usage report for October 2024 is now available. View it in your dashboard or download the attached PDF... 836 | Attachments: 2 837 | - october-report.pdf (523.1 KB) 838 | - usage-chart.png (89.3 KB) 839 | 840 | 📊 Mailbox Statistics: 841 | Total emails: 1532 842 | Read emails: 1530 843 | Unread emails: 2 844 | Flagged emails: 23 845 | 846 | 👀 Monitoring for new emails (10 seconds)... 847 | 848 | ✅ Done! 849 | */ 850 | ``` 851 | 852 | ## Reconnect Behavior 853 | 854 | When a command fails, the library closes the socket, reconnects, re‑authenticates (LOGIN or XOAUTH2), and restores the previously selected folder. You can tune retry count via `imap.RetryCount`. 855 | 856 | ## TLS & Certificates 857 | 858 | Connections are TLS by default. For servers with self‑signed certs you can set `imap.TLSSkipVerify = true`, but be aware this disables certificate validation and can expose you to man‑in‑the‑middle attacks. Prefer real certificates in production. 859 | 860 | ## Server Compatibility 861 | 862 | Tested against common providers such as Gmail and Office 365/Exchange. The client targets RFC 3501 and common extensions used for search, fetch, and move. 863 | 864 | ## CI & Quality 865 | 866 | This repo runs Go 1.25.1+ on CI with vet and race‑enabled tests. We also track documentation on pkg.go.dev and Go Report Card. 867 | 868 | ## Contributing 869 | 870 | Issues and PRs are welcome! If adding public APIs, please include short docs and examples. Make sure `go vet` and `go test -race ./...` pass locally. 871 | 872 | ## License 873 | 874 | MIT © Brian Leishman 875 | 876 | --- 877 | 878 | ### Built With 879 | 880 | - [jhillyerd/enmime](https://github.com/jhillyerd/enmime) – MIME parsing 881 | - [dustin/go-humanize](https://github.com/dustin/go-humanize) – Human‑friendly sizes 882 | --------------------------------------------------------------------------------