245 lines
8.8 KiB
Go
245 lines
8.8 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
|
|
}
|
|
|
|
// ReadSecret and SecretPath moved into cmd/mesh-mail/internal/secret (novox/hq ADR 0436 §12, issue 1193): only
|
|
// keep (imap.go) and the sender's own actor (sendactor.go) read a password, never the tools, which the operator
|
|
// account's tool runner serves and which must never open a password file.
|
|
|
|
// 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. It computes the
|
|
// secret's path itself (not through the moved package) so the tools, which call this, never import it.
|
|
func SecretGiven(dir string, a Account) bool {
|
|
info, err := os.Stat(filepath.Join(dir, a.Secret()+".secret"))
|
|
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
|
|
}
|