Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/config.go
T
jochen be53f5167a
mesh/merge-gate out of date: judged against trunk 2130f9961291, the trunk is now 5f6a41146c3a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2130f9961291, the trunk is now 5f6a41146c3a; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
mesh-mail: bound a fetch by bytes, show mail_accounts only to its grantees, say why grants are unread, judge a secret without reading it (hq issue 733)
The review of #300 found a batch of 25 whole messages could hold a gigabyte,
mail_accounts told ungranted callers folder names, counts and server errors,
an unreadable grants setting was refused without its reason, and the watch
read the password only to learn it was there.
2026-10-14 22:24:11 +02:00

261 lines
9.1 KiB
Go

package main
// The operator's mail accounts and who may read them (novox/hq ADR 0359 §1, §4, §5): two trusted settings, each a
// file the mesh writes from the module's settings and changes only on the operator's approval. `accounts` says which
// server each account is on and how to sign in (never a credential); `grants` says, per account, which callers may
// read its mail. The password of an account is the module's own secret mail-credential-<account>, given at the desk
// (ADR 0283 §6), at the path the secrets directory and that name make.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// The kinds of account an adapter exists for. Only `imap` is built (ADR 0359 §5); `imap-oauth` and `graph` are later
// decisions (§7), and an account of a kind not listed here is refused by name.
const KindIMAP = "imap"
// Kinds is every kind this version has an adapter for.
var Kinds = []string{KindIMAP}
// SecretFamily is the prefix of every account's own secret: mail-credential-<account> (ADR 0283 §6).
const SecretFamily = "mail-credential-"
// accountName is what an account may be called: a member name of the secret family (ADR 0283 §6) and one token of a
// bus subject, so never a dot, a slash or an upper-case letter.
var accountName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}$`)
// Server is where an account's mail is reached.
type Server struct {
Host string `json:"host"`
Port int `json:"port"`
// Security is tls (TLS from the first byte) or starttls (plain, then upgraded before signing in). Nothing
// signs in over a connection that is not encrypted.
Security string `json:"security"`
}
// Address is host:port.
func (s Server) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) }
// Account is one of the operator's mail accounts, as the setting `accounts` declares it.
type Account struct {
// Name is the key the account has in the setting; set when the setting is read.
Name string `json:"-"`
Kind string `json:"kind"`
// Address is the account's mail address, said only to a caller granted the account.
Address string `json:"address"`
// User is the name it signs in with; the address when empty.
User string `json:"user,omitempty"`
Incoming Server `json:"incoming"`
Outgoing *Server `json:"outgoing,omitempty"`
// Folders are the folders watched besides the inbox; every folder the server lists when empty.
Folders []string `json:"folders,omitempty"`
}
// SignInAs is the name the account signs in with.
func (a Account) SignInAs() string {
if a.User != "" {
return a.User
}
return a.Address
}
// Secret is the name of the account's own secret.
func (a Account) Secret() string { return SecretFamily + a.Name }
// AccountsFile is the shape of the trusted setting `accounts`, as the mesh writes it.
type AccountsFile struct {
Accounts map[string]Account `json:"accounts"`
}
// ReadAccounts reads the accounts setting, refusing it whole when any account in it cannot be watched: an account
// half-understood would be watched wrongly, and the operator approved the setting as one.
func ReadAccounts(path string) ([]Account, error) {
if path == "" {
return nil, errors.New("the module was started without its accounts setting (MESH_MAIL_ACCOUNTS)")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("the accounts setting cannot be read: %w", err)
}
return ParseAccounts(raw)
}
// ParseAccounts reads the accounts setting's content.
func ParseAccounts(raw []byte) ([]Account, error) {
var f AccountsFile
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("the accounts setting is not the module's: %v", err)
}
var out []Account
var problems []string
for name, a := range f.Accounts {
a.Name = name
if err := a.Check(); err != nil {
problems = append(problems, err.Error())
continue
}
out = append(out, a)
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, errors.New("the accounts setting is refused: " + strings.Join(problems, "; "))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// Check says why an account cannot be watched, or nil.
func (a Account) Check() error {
say := func(format string, args ...any) error {
return fmt.Errorf("the account %q: %s", a.Name, fmt.Sprintf(format, args...))
}
if !accountName.MatchString(a.Name) {
return say("is not an account's name: a lower-case letter or digit, then lower-case letters, digits and hyphens, at most 40")
}
known := false
for _, k := range Kinds {
known = known || a.Kind == k
}
if !known {
return say("is of the kind %q, and this version watches only %s", a.Kind, strings.Join(Kinds, ", "))
}
if !strings.Contains(a.Address, "@") {
return say("has no mail address")
}
if err := a.Incoming.check("incoming"); err != nil {
return say("%v", err)
}
if a.Outgoing != nil {
if err := a.Outgoing.check("outgoing"); err != nil {
return say("%v", err)
}
}
for _, f := range a.Folders {
if strings.TrimSpace(f) == "" || strings.EqualFold(f, "INBOX") {
return say("lists the folder %q, and the inbox is always watched", f)
}
}
return nil
}
func (s Server) check(which string) error {
switch {
case strings.TrimSpace(s.Host) == "" || strings.ContainsAny(s.Host, " /:"):
return fmt.Errorf("its %s server has no host name", which)
case s.Port <= 0 || s.Port > 65535:
return fmt.Errorf("its %s server's port %d is not a port", which, s.Port)
case s.Security != "tls" && s.Security != "starttls":
return fmt.Errorf("its %s server's security is %q, and only tls or starttls signs in", which, s.Security)
}
return nil
}
// SecretPath is where the account's own secret is placed in the secrets directory, as the manifest declares the
// family: <dir>/mail-credential-<account>.secret.
func SecretPath(dir string, a Account) string { return filepath.Join(dir, a.Secret()+".secret") }
// ReadSecret is the account's password, or given false when nobody gave it yet: the mesh never makes a member of the
// family, so a member not given is no file (ADR 0283 §6).
func ReadSecret(dir string, a Account) (string, bool, error) {
raw, err := os.ReadFile(SecretPath(dir, a))
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
pw := strings.TrimRight(string(raw), "\r\n")
if pw == "" {
return "", false, nil
}
return pw, true, nil
}
// SecretGiven says whether the account's own secret was given.
//
// It looks at the file and never reads it: whoever judges whether a password was given has no need of the password.
// A file that is there and not empty was given; whether it signs in is the keeper's to say.
func SecretGiven(dir string, a Account) bool {
info, err := os.Stat(SecretPath(dir, a))
return err == nil && info.Mode().IsRegular() && info.Size() > 0
}
// ---- grants ------------------------------------------------------------------------------------------------------
// Grants is the trusted setting `grants`: per account, the callers that may read its mail (ADR 0359 §4). An entry is a
// module's name (granted wherever it runs), a module on one machine (`<machine>.<module>`) or a person
// (`person.<name>`), as the runtime names callers.
type Grants map[string][]string
// GrantsFile is the shape of the setting as the mesh writes it.
type GrantsFile struct {
Grants Grants `json:"grants"`
}
// ReadGrants reads the grants setting. A setting that cannot be read grants nothing, and says so.
func ReadGrants(path string) (Grants, error) {
if path == "" {
return Grants{}, errors.New("the module was started without its grants setting (MESH_MAIL_GRANTS)")
}
raw, err := os.ReadFile(path)
if err != nil {
return Grants{}, fmt.Errorf("the grants setting cannot be read: %w", err)
}
var f GrantsFile
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return Grants{}, fmt.Errorf("the grants setting is not the module's: %v", err)
}
if f.Grants == nil {
f.Grants = Grants{}
}
return f.Grants, nil
}
// Unproven is the caller the runtime proved nobody to be; it is granted nothing.
const Unproven = "unproven"
// Allows says whether the caller may read the account's mail. Nobody is granted an account by default, an unproven
// caller never, and a wildcard is no entry.
func (g Grants) Allows(caller, account string) bool {
if caller == "" || caller == Unproven {
return false
}
for _, entry := range g[account] {
if entry == "" || strings.ContainsAny(entry, "*>") {
continue
}
if entry == caller {
return true
}
// A module's name alone grants it on every machine: the caller is <machine>.<module>.
if !strings.Contains(entry, ".") {
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
return true
}
}
}
return false
}
// Granted is the accounts, of those given, the caller may read.
func (g Grants) Granted(caller string, accounts []Account) []string {
var out []string
for _, a := range accounts {
if g.Allows(caller, a.Name) {
out = append(out, a.Name)
}
}
return out
}