commit cb474497f0adffac4fd2f79500e62f32bff27ea3 from: Oliver Lowe date: Tue Apr 19 07:37:02 2022 UTC wip commit - 052981c3e4c54235c8bf047902fb6646eb57dde7 commit + cb474497f0adffac4fd2f79500e62f32bff27ea3 blob - ed8f12e75215abdcf7aebf7505255a9dede42fc5 blob + 3e185bc025d1f54a717d41a63664fda8de1571cf --- alias.go +++ alias.go @@ -2,15 +2,27 @@ package mailmux import ( "database/sql" + "errors" "fmt" "os" "time" ) +// An Alias is used by a server to forward mail it receives. +// Mail addressed to Recipient is forwarded to Destination. type Alias struct { + // Recipient is just the username part of a publicly visible email address. + // The domain(s) available for accepting mail depend on the SMTP server + // implementation. Recipient string + // Destination contains an email address, with domain suffix, + // to which mail will be forwarded. Destination string + // Expiry specifies a time after which the alias is considered inactive; + // that is, mail addressed to Recipient should be bounced. Expiry time.Time + // Note contains user-defined text that can be used to, + // for example, identify the alias. Note string } @@ -20,11 +32,17 @@ type AliasStore interface { Delete(rcpt string) error } +// AliasDB is an implementation of AliasStore backed by a sqlite3 database. type AliasDB struct { *sql.DB dictpath string } +var errRecipientNotExist = errors.New("no such recipient") + +// OpenAliasDB opens the named database file, using the file at dictpath for +// generating recipient names for new aliases. The database is created and +// initialised if it doesn't already exist. func OpenAliasDB(name, dictpath string) (*AliasDB, error) { db, err := sql.Open("sqlite3", name) if err != nil { @@ -34,8 +52,8 @@ func OpenAliasDB(name, dictpath string) (*AliasDB, err CREATE TABLE IF NOT EXISTS aliases ( recipient TEXT PRIMARY KEY, destination TEXT NOT NULL, - expiry INTEGER, - note TEXT + expiry INTEGER NOT NULL, + note TEXT NOT NULL );` _, err = db.Exec(stmt) if err != nil { @@ -45,44 +63,74 @@ CREATE TABLE IF NOT EXISTS aliases ( return &AliasDB{db, dictpath}, err } -func (db *AliasDB) Create(dest string) (Alias, error) { +// Create is a convenience method for creating a new random alias for destination. +// To set more Alias attributes, such as setting an expiry date, use Put. +func (db *AliasDB) Create(destination string) (Alias, error) { rcpt, err := RandomUsername(db.dictpath) if err != nil { return Alias{}, fmt.Errorf("create alias: %w", err) } - _, err = db.Exec("INSERT INTO aliases (recipient, destination) VALUES (?, ?)", rcpt, dest) - if err != nil { - return Alias{}, fmt.Errorf("create alias: %w", err) + a := Alias{ + Recipient: rcpt, + Destination: destination, } - return Alias{Recipient: rcpt, Destination: dest}, nil + if err := db.Put(a); err != nil { + return Alias{}, fmt.Errorf("put %s: %w", a.Recipient, err) + } + return a, nil } +// Put creates or updates the given alias in db. +func (db *AliasDB) Put(a Alias) error { + _, err := db.Lookup(a.Recipient) + if err != nil && !errors.Is(err, errRecipientNotExist) { + return fmt.Errorf("lookup %s: %w", a.Recipient, err) + } + var q string + if errors.Is(err, errRecipientNotExist) { + q = "INSERT INTO aliases (recipient, destination, expiry, note) VALUES (?, ?, ?, ?)" + } else if err == nil { + q = "UPDATE aliases (recipient, destination, expiry, note) VALUES(?, ?, ?, ?)" + } + _, err = db.Exec(q, a.Recipient, a.Destination, a.Expiry.Unix(), a.Note) + return err +} + +// Lookup returns the Alias with the recipient rcpt. If no alias exists, an error is returned. +func (db *AliasDB) Lookup(rcpt string) (Alias, error) { + var a Alias + q := "SELECT recipient, destination, expiry, note FROM ALIASES WHERE recipient = ?" + err := db.QueryRow(q, rcpt).Scan(&a.Recipient, &a.Destination, &a.Expiry, &a.Note) + if errors.Is(err, sql.ErrNoRows) { + return Alias{}, errRecipientNotExist + } else if err != nil { + return Alias{}, err + } + return a, nil +} + +// Aliases returns all aliases who have their destination address as dest. func (db *AliasDB) Aliases(dest string) ([]Alias, error) { rows, err := db.Query("SELECT recipient, destination, expiry, note FROM aliases WHERE destination = ?", dest) if err != nil { - return nil, fmt.Errorf("aliases for %s: %w", dest, err) + return nil, err } defer rows.Close() var aliases []Alias for rows.Next() { var a Alias - var t sql.NullTime - var note sql.NullString - err := rows.Scan(&a.Recipient, &a.Destination, &t, ¬e) - if err != nil { + var sec int64 + if err := rows.Scan(&a.Recipient, &a.Destination, &sec, &a.Note); err != nil { return aliases, err } - if t.Valid { - a.Expiry = t.Time - } - if note.Valid { - a.Note = note.String - } + a.Expiry = time.Unix(sec, 0) aliases = append(aliases, a) } return aliases, rows.Err() } +// Delete deletes any alias with recipient rcpt. +// No error is returned if no alias exists with the given recipient. func (db *AliasDB) Delete(rcpt string) error { _, err := db.Exec("DELETE FROM aliases WHERE recipient = ?", rcpt) if err != nil { blob - 1e2d1a189ba11c0a2b3ceb7aaa37685ba5ee67e0 blob + ea0912002994073ff2e272d13fde8a00e8cd538e --- client.go +++ client.go @@ -3,6 +3,7 @@ package mailmux import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" ) @@ -57,7 +58,7 @@ func (c *Client) Register(username, password string) e func (c *Client) NewAlias() ([]Alias, error) { tmsg := &Mcall{ - Type: Tnew, + Type: Tcreate, Username: c.user, Password: c.token, } @@ -89,20 +90,20 @@ func (c *Client) Aliases() ([]Alias, error) { } buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(tmsg); err != nil { - return nil, fmt.Errorf("list aliases: %w", err) + return nil, fmt.Errorf("encode tmsg: %w", err) } resp, err := http.Post(c.url+"/alias", jsonContentType, buf) if err != nil { - return nil, fmt.Errorf("list aliases: %w", err) + return nil, err } defer resp.Body.Close() rmsg, err := ParseMcall(resp.Body) if err != nil { - return nil, fmt.Errorf("list aliases: parse response: %w", err) + return nil, fmt.Errorf("parse response: %w", err) } if rmsg.Type == Rerror { - return nil, fmt.Errorf("list aliases: %v", rmsg.Error) + return nil, errors.New(rmsg.Error) } return rmsg.Aliases, nil } blob - 252dbce017446a93961926b7f8636ad5df4d5a4e blob + faa7d1977d89b20452eabe3d25e5d493f78795f4 --- cmd/mailmux/mailmux_test.go +++ cmd/mailmux/mailmux_test.go @@ -78,7 +78,7 @@ func TestAliases(t *testing.T) { } a, err := client.Aliases() if err != nil { - t.Fatal(err) + t.Fatal("list aliases:", err) } t.Log(a) } blob - 6b7bc2b7ecae600709724f269936478d9658cf2b blob + 460d3d27b5cd3607ac2b6e52be7b3eb9091d73f1 --- go.mod +++ go.mod @@ -3,6 +3,6 @@ module mailmux.net go 1.17 require ( - github.com/mattn/go-sqlite3 v1.14.12 // indirect - golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect + github.com/mattn/go-sqlite3 v1.14.12 + golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 ) blob - 8c144c4624fd750edac385562d6df627ece50404 blob + 275add587c519278a2643e428b56045b6021b10a --- mcall.go +++ mcall.go @@ -11,11 +11,12 @@ const ( Tregister = 1 + iota Rregister Rerror - Tnew - Rnew + Tcreate + Rcreate Tlist Rlist Tremove + Rremove ) // Mcall represents a message passed between mailmux clients and servers. @@ -30,7 +31,7 @@ type Mcall struct { Password string `json:",omitempty"` // Tregister Error string `json:",omitempty"` // Rerror Aliases []Alias `json:",omitempty"` // Rnew, Rlist, Tremove - Expiry time.Time `json:",omitempty"` // Tnew, Rnew + Expiry time.Time `json:",omitempty"` // Tcreate, Rnew } // ParseMcall parses and validates a JSON-encoded Mcall from r. @@ -44,7 +45,7 @@ func ParseMcall(r io.Reader) (*Mcall, error) { if mc.Error == "" { return nil, errors.New("empty error message") } - case Tregister, Tnew, Tlist, Tremove: + case Tregister, Tcreate, Tlist, Tremove: if mc.Username == "" { return nil, errors.New("empty username") } else if mc.Password == "" { blob - d0d25d70ddf134d338c0dd36d983b041926a1a3c blob + 50982ee30965a523551740de2e702c7df1bd933b --- server.go +++ server.go @@ -92,7 +92,7 @@ func (srv *Server) aliasHandler(w http.ResponseWriter, var rmsg *Mcall switch tmsg.Type { - case Tnew: + case Tcreate: rmsg = srv.newAlias(tmsg) case Tlist: rmsg = srv.listAliasHandler(tmsg) @@ -113,13 +113,13 @@ func (srv *Server) newAlias(tmsg *Mcall) *Mcall { if err != nil { return &Mcall{Type: Rerror, Error: err.Error()} } - return &Mcall{Type: Rnew, Username: tmsg.Username, Aliases: []Alias{alias}} + return &Mcall{Type: Rcreate, Username: tmsg.Username, Aliases: []Alias{alias}} } func (srv *Server) listAliasHandler(tmsg *Mcall) *Mcall { a, err := srv.aliases.Aliases(tmsg.Username) if err != nil { - return &Mcall{Type: Rerror, Error: err.Error()} + return &Mcall{Type: Rerror, Error: fmt.Sprintf("aliases for %s: %v", tmsg.Username, err)} } return &Mcall{Type: Rlist, Username: tmsg.Username, Aliases: a} }