Commit Diff


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, &note)
-		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}
 }