- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
nagonag (Push) / nagonag (push) Successful in 1m3s
|
||
| .forgejo/workflows | ||
| docs | ||
| acceptance_test.go | ||
| AGENTS.md | ||
| append.go | ||
| append_test.go | ||
| auth.go | ||
| auth_test.go | ||
| client.go | ||
| client_test.go | ||
| config_url_test.go | ||
| conn.go | ||
| doc.go | ||
| errors.go | ||
| examine_test.go | ||
| example_test.go | ||
| fetch.go | ||
| fixes_test.go | ||
| go.mod | ||
| go.sum | ||
| idle.go | ||
| LICENSE.txt | ||
| limiter_test.go | ||
| list.go | ||
| list_test.go | ||
| logging_test.go | ||
| logwriter.go | ||
| namespace.go | ||
| namespace_test.go | ||
| NOTICE | ||
| notify.go | ||
| parser.go | ||
| password.go | ||
| password_test.go | ||
| peek.go | ||
| peek_test.go | ||
| README.md | ||
| sanitize.go | ||
| sanitize_test.go | ||
| search.go | ||
| search_test.go | ||
| security.go | ||
| security_test.go | ||
| select.go | ||
| store.go | ||
| testserver_test.go | ||
| token.go | ||
| types.go | ||
| utf7.go | ||
| validate.go | ||
imapclient
A small, dependency-light IMAP client for Go built directly on RFC 3501, with optional CONDSTORE (RFC 7162) and NAMESPACE (RFC 2342) support.
- Context-cancellable I/O on every operation
- Self-redacting
PasswordandTokentypes — credentials cannot leak viafmt,slog, or JSON - LOGIN, XOAUTH2, and OAUTHBEARER (RFC 7628) authentication
- Mandatory STARTTLS by default; the zero-value
Configis safe - Structured logging via
*slog.Logger(stdlib); wire transcript viaio.Writer - Token-bucket rate limiter built in
Status: pre-1.0 (v0.x). The API may change.
Runtime dependency: stdlib + golang.org/x/time/rate (BSD-3).
Install
go get go.schlittermann.de/heiko/imapclient
Requires Go 1.26 or newer.
Quick start
import "go.schlittermann.de/heiko/imapclient"
cfg := imapclient.Config{
Host: "imap.example.com",
Port: 993,
TLS: true, // implicit TLS
Username: "alice@example.com",
Password: imapclient.NewPassword(os.Getenv("IMAP_PASSWORD")),
Mailbox: "INBOX",
// CondStore: true, // SELECT with CONDSTORE (RFC 7162), if supported
Logger: slog.Default(), // optional
}
c, mbox, err := imapclient.Connect(ctx, cfg)
if err != nil {
log.Fatal(err)
}
defer c.Close()
uids, err := c.SearchUIDs(ctx, imapclient.SearchCriteria{
Since: time.Now().AddDate(0, 0, -7),
NotFlag: imapclient.FlagSeen,
})
if err != nil {
log.Fatal(err)
}
for _, uid := range uids {
body, err := c.FetchBody(ctx, uid)
if err != nil {
log.Fatal(err)
}
_ = body // parse with net/mail, etc.
if err := c.Store(ctx, uid, imapclient.FlagAdd, imapclient.FlagSeen); err != nil {
log.Fatal(err)
}
}
_ = mbox.Exists // message count from SELECT
See the package examples
for FetchBodyPeek, SearchUIDs criteria, Append, Namespace, Idle,
and the NewLogWriter bridge.
Transport security
The zero value of Config is safe: it requires STARTTLS before sending any
credentials. Dial returns ErrSTARTTLSUnsupported if the server does not
advertise it — before LOGIN.
| Config | Transport | Port |
|---|---|---|
TLS: true |
Implicit TLS (handshake before any IMAP bytes) | 993 |
| (default) | Mandatory STARTTLS (plaintext upgraded before LOGIN) | 143 |
InsecurePlaintext: true |
Plaintext only — local testing only | any |
There is no "STARTTLS optional" mode — that is a downgrade-attack surface.
Each insecure opt-out fires Config.OnSecurityEvent and logs at Warn:
| Field | Kind | Effect |
|---|---|---|
TLSNoVerify |
TLSNoVerifyActive |
skip certificate verification |
InsecurePlaintext |
STARTTLSDisabled |
no TLS at all |
InsecureDebug |
InsecureDebugActive |
password / token in debug transcript |
cfg.OnSecurityEvent = func(e imapclient.SecurityEvent) {
// e.Kind.String() → "tls_no_verify_active" etc.
myAuditLog.Warn(e.Kind.String(), "host", e.Host, "source", e.Name)
}
Credentials
Password redacts itself in every fmt, log/slog, JSON, and TOML path.
Reveal() is the only exit, called once inside Login (or, for Token,
the AUTHENTICATE methods) just before the bytes go on the wire.
pw := imapclient.NewPassword(secret)
fmt.Println(pw) // → [redacted]
slog.Any("pw", pw) // → pw=[redacted]
json.Marshal(pw) // → "[redacted]"
pw.Reveal() // → secret (use once, immediately)
Error messages are sanitised so a hostile server echoing the LOGIN or AUTHENTICATE line back cannot leak the credential through the error chain.
OAuth2 (XOAUTH2, OAUTHBEARER)
cfg := imapclient.Config{
Host: "imap.gmail.com",
Port: 993,
TLS: true,
Username: "alice@gmail.com",
Auth: imapclient.AuthXOAUTH2, // or imapclient.AuthOAUTHBEARER
OAuth2TokenFunc: func(ctx context.Context) (imapclient.Token, error) {
tok, err := ts.Token() // e.g. golang.org/x/oauth2 TokenSource
if err != nil {
return imapclient.Token{}, err
}
return imapclient.NewToken(tok.AccessToken), nil
},
}
OAuth2TokenFuncis called on everyConnect/Reconnect, so a refreshing source keeps long-running clients working.StaticToken(t)wraps a fixed token.- Getting and refreshing tokens is up to the caller; this package adds no OAuth2 dependency.
- The server must advertise
AUTH=<mech>, otherwiseErrAuthMechanismUnsupportedis returned before the token is sent. SASL-IR (RFC 4959) is used when advertised. - A rejection returns an error wrapping
ErrAuthFailedandErrNo, with the server's decoded error challenge (e.g.{"status":"401",...}) appended. Tokenredacts likePassword; the AUTHENTICATE line is masked in the debug transcript unlessInsecureDebugis set.Client.AuthenticateXOAUTH2/Client.AuthenticateOAUTHBEARERcan be called directly afterDial.- OAUTHBEARER sends
Usernameas the GS2 authorization identity andHost/Portas thehost/portattributes (RFC 7628 §3.1). - Verified against Dovecot (Debian) for both mechanisms, using SASL-IR. Not yet verified against Gmail or Microsoft 365.
Logging
Inject a *slog.Logger via Config.Logger (nil = silent):
cfg.Logger = slog.Default()
// Other stacks:
// zap: slog.New(zapslog.NewHandler(zapLogger.Core()))
// logrus: slog.New(sloglogrus.Option{Logger: l}.NewLogrusHandler())
// log: slog.New(slog.NewTextHandler(log.Writer(), nil))
| Event | Level | Attrs |
|---|---|---|
| connected | Debug | name, host, tls=implicit|starttls|plaintext |
| reconnected | Debug | name, host |
| close | Debug | name, host |
| insecure opt-out | Warn | event, name, host |
The package never logs credentials or message content.
Wire transcript → slog
Route the raw IMAP wire transcript through the same logger with NewLogWriter:
cfg.DebugWriter = imapclient.NewLogWriter(cfg.Logger, slog.LevelDebug)
// → msg="imap wire" dir=client|server line="A3 UID SEARCH ALL"
Passwords are redacted before they reach the writer (unless InsecureDebug
is set). Note that the transcript can include message content (small FETCH
literals are transcribed verbatim) — routing it into a log store is a
data-sensitivity decision.
Rate limiting
A token-bucket rate limiter is applied before every outgoing command (default: 5 cmd/s, burst 10). Tune or disable it:
cfg.CommandsPerSec = 10 // 10/s, burst 10
cfg.CommandsBurst = 20 // raise burst
cfg.CommandsPerSec = -1 // unlimited (no limiter)
The limiter respects context.Context cancellation.
Scope and limitations
This package implements the operations its consumers need — not all of RFC 3501:
- Auth: LOGIN, XOAUTH2, OAUTHBEARER; no other SASL mechanisms (PLAIN, SCRAM-*, ...), single-step SASL exchanges only
- Parsers: ENVELOPE, ADDRESS, NAMESPACE — sufficient for common servers, not a strict RFC parser; unusual formatting may parse as empty fields
- Concurrency: a
Clientis not goroutine-safe; use one per goroutine or synchronise externally
License
Apache-2.0. See LICENSE and NOTICE.