- Go 100%
|
All checks were successful
nagonag (Push) / nagonag (push) Successful in 3m8s
- NewLogWriter doc + README: state explicitly that the wire transcript can carry message content (small FETCH literals are transcribed verbatim) — routing it into a log store is a data-sensitivity decision, not just a verbosity one - logWriter.Write: strip trailing \r so foreign writers feeding CRLF cannot smuggle a stray carriage return into the line attr - log message tense: 'imap close' → 'imap closed' (matches 'connected'/'reconnected') - README Quick start: full error handling (was eliding errors); align env var name with examples (IMAP_PASSWORD) 81 tests pass, race-clean; staticcheck clean. (co)authored by ai:claude-sonnet-4-5 |
||
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| acceptance_test.go | ||
| append.go | ||
| append_test.go | ||
| client.go | ||
| client_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 | ||
| 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 | ||
| 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
Passwordtype — credentials cannot leak viafmt,slog, or JSON - 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",
Logger: slog.Default(), // optional
}
c, mbox, err := imapclient.Connect(ctx, cfg, false)
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 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 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 line back cannot leak the password through the error chain.
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 only; no SASL/AUTHENTICATE (OAUTH2, XOAUTH2, etc.)
- 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.