← repoman internals

internal/email

import "git.griefed.de/griefed/repoman/internal/email"
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.