package email // import "git.griefed.de/griefed/repoman/internal/email" Package email implements the optional outbound-email functionality used for self-service password resets. Email is OPTIONAL throughout repoman. When SMTP is not configured, the sender's IsConfigured() returns false and handlers must skip any code path that depends on email delivery (the "Forgot password?" link is hidden from the login page in that case). When SMTP IS configured, this package provides a small templated surface (currently just password-reset) that other parts of the app can call without knowing anything about the SMTP plumbing. All emails are PLAIN TEXT. No HTML by design — repoman is an internal admin tool and the operator audience does not need marketing-mail formatting; plain text is also more accessible to screen readers, terminal mail clients, and operators who deliver the mail through restrictive corporate gateways. VARIABLES var ErrNotConfigured = errors.New("smtp is not configured") ErrNotConfigured is returned by Sender methods when SMTP is not configured. Callers should distinguish this from real send errors (network failure, auth rejection, etc.) and surface it to the user as "email-based reset is not available; ask an admin". TYPES type Config struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` FromAddress string `json:"from_address"` FromName string `json:"from_name"` TLSMode string `json:"tls_mode"` AuthMechanism string `json:"auth_mechanism"` Timeout string `json:"timeout"` // duration string, e.g. "10s" } Config holds SMTP connection settings. Loaded from the active configuration's content_json under the "email" key. Empty Host means "email disabled" — every Sender method becomes a no-op that returns ErrNotConfigured so callers can detect the disabled state. AuthMechanism: "plain" | "login" | "cram-md5" | "" (auto-detect). TLSMode: - "starttls" — connect plaintext, upgrade with STARTTLS (port 587) - "tls" — connect over TLS from the start (port 465 / 993) - "none" — no encryption; allowed only when host is localhost or 127.0.0.1, refused elsewhere func ConfigFromSMTP(c globalconfig.SMTPCfg) Config ConfigFromSMTP maps the operator-facing globalconfig.SMTPCfg onto the transport-level Config. It is the SINGLE source of truth for that translation, so the live sender (NewDynamic) and the settings "test connection" probe cannot drift on it. A disabled config (Enabled=false) maps to the zero Config, whose IsConfigured() is false — so a disabled SMTP setting cleanly becomes ErrNotConfigured at send time. The single UseTLS toggle maps to TLSMode "tls" (implicit TLS / SMTPS) when set and "starttls" otherwise, matching the one checkbox the settings UI exposes. func (c Config) IsConfigured() bool IsConfigured returns true if Host is non-empty. Used by the login handler to decide whether to render the "Forgot password?" link. func (c Config) Validate() error Validate checks that the configuration is internally consistent. Called once at SMTP-config save time AND again at send time so a silently-broken config does not stop being detected if it was already in the DB before validation existed. type Sender interface { IsConfigured() bool SendPasswordReset(ctx context.Context, toAddress, username, resetURL string, validity time.Duration) error // SendTokenExpiryWarning notifies one recipient that an editable access // token is nearing expiry. tokenLocation describes where the token lives. SendTokenExpiryWarning(ctx context.Context, toAddress, tokenLocation, platform string, expiresAt time.Time) error // SendNotification emails an in-app notification (title + body + optional // absolute link) to one recipient. SendNotification(ctx context.Context, toAddress, title, body, link string) error // TestConnection opens — and authenticates, when credentials are set // — a connection to the SMTP server, then immediately closes it // without sending a message. Backs the settings UI's "test // connection" button. Returns ErrNotConfigured when SMTP is disabled. TestConnection(ctx context.Context) error } Sender is the high-level interface other packages depend on. The production implementation lives in this file; tests substitute a fake that records sent messages without touching SMTP. func New(cfg Config) (Sender, error) New returns a Sender configured from cfg. If cfg.IsConfigured() returns false, the returned Sender is a working object whose methods all return ErrNotConfigured — convenient for callers that always wire a Sender and let the disabled state be a runtime check. func NewDynamic(loader SettingsLoader) Sender NewDynamic returns a live-reloading Sender backed by loader. Unlike New it cannot fail at construction time: configuration is resolved lazily on each call, so an absent or invalid config only surfaces when an email is actually sent (or as ErrNotConfigured when SMTP is disabled). type SettingsLoader interface { Load(ctx context.Context) (*globalconfig.Settings, error) } SettingsLoader supplies the active global settings to a dynamicSender. *globalconfig.Service satisfies it; tests substitute a fake. Kept narrow (interface segregation) so the email package depends only on "load the settings", not on the persistence layer behind it.