No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-10 20:37:59 +02:00
.forgejo/workflows ci: drop unsupported permissions block from nagonag callers ai:claude-opus-5 2026-09-25 23:56:53 +02:00
docs feat!: move condStore into Config.CondStore 2026-10-10 20:33:48 +02:00
acceptance_test.go feat!: move condStore into Config.CondStore 2026-10-10 20:33:48 +02:00
AGENTS.md docs: add AGENTS.md ai:claude-sonnet-5-5 2026-10-10 20:35:19 +02:00
append.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
append_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
auth.go feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
auth_test.go feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
client.go feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
client_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
config_url_test.go feat: add ConfigFromURL to build a Config from imap(s):// URLs 2026-10-10 19:52:37 +02:00
conn.go chore: post-review cleanup ai:claude-sonnet-4-5 2026-06-17 12:00:34 +02:00
doc.go feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
errors.go feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
examine_test.go feat!: move condStore into Config.CondStore 2026-10-10 20:33:48 +02:00
example_test.go feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
fetch.go docs: add runnable usage examples ai:claude-sonnet-4-5 2026-06-17 12:05:06 +02:00
fixes_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
go.mod feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
go.sum deps(master): update minor/patch dependencies 2026-09-15 05:25:02 +00:00
idle.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
LICENSE.txt rename and update LICENSE 2026-10-10 19:54:52 +02:00
limiter_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
list.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
list_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
logging_test.go feat!: move condStore into Config.CondStore 2026-10-10 20:33:48 +02:00
logwriter.go polish: review nits on logger feature and docs ai:claude-sonnet-4-5 2026-07-03 17:45:33 +02:00
namespace.go chore: post-review cleanup ai:claude-sonnet-4-5 2026-06-17 12:00:34 +02:00
namespace_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
NOTICE feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
notify.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
parser.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
password.go feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
password_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
peek.go docs: expand package doc, examples, and README ai:claude-sonnet-4-5 2026-07-03 17:38:09 +02:00
peek_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
README.md feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
sanitize.go feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
sanitize_test.go feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
search.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
search_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
security.go feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
security_test.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
select.go feat!: move condStore into Config.CondStore 2026-10-10 20:33:48 +02:00
store.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
testserver_test.go feat: XOAUTH2 and OAUTHBEARER authentication ai:claude-opus-5-5 2026-10-09 22:36:58 +02:00
token.go feat!: rename Config.OAuth2Token to Config.OAuth2TokenFunc ai:claude-sonnet-5-5 2026-10-10 20:37:59 +02:00
types.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
utf7.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00
validate.go feat: initial extraction of imapclient package ai:claude-sonnet-4-5 2026-06-17 11:54:56 +02:00

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 Password and Token types — credentials cannot leak via fmt, slog, or JSON
  • LOGIN, XOAUTH2, and OAUTHBEARER (RFC 7628) authentication
  • Mandatory STARTTLS by default; the zero-value Config is safe
  • Structured logging via *slog.Logger (stdlib); wire transcript via io.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
    },
}
  • OAuth2TokenFunc is called on every Connect / 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>, otherwise ErrAuthMechanismUnsupported is returned before the token is sent. SASL-IR (RFC 4959) is used when advertised.
  • A rejection returns an error wrapping ErrAuthFailed and ErrNo, with the server's decoded error challenge (e.g. {"status":"401",...}) appended.
  • Token redacts like Password; the AUTHENTICATE line is masked in the debug transcript unless InsecureDebug is set.
  • Client.AuthenticateXOAUTH2 / Client.AuthenticateOAUTHBEARER can be called directly after Dial.
  • OAUTHBEARER sends Username as the GS2 authorization identity and Host/Port as the host/port attributes (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 Client is not goroutine-safe; use one per goroutine or synchronise externally

License

Apache-2.0. See LICENSE and NOTICE.