Commit Diff


commit - 5e622b9bb50c661a19f5b6b32666e6f880776fca
commit + 925168e0252fd037ac6e89d98f75d613efdadef2
blob - /dev/null
blob + 3f859921e3e13b963781a960269da12b6986c579 (mode 644)
--- /dev/null
+++ alias.go
@@ -0,0 +1,87 @@
+package mailmux
+
+import (
+	"database/sql"
+	"fmt"
+	"time"
+)
+
+type Alias struct {
+	Recipient   string
+	Destination string
+	Expiry      time.Time
+	Note        string
+}
+
+type AliasStore interface {
+	Create(dest string) (Alias, error)
+	Aliases(dest string) ([]Alias, error)
+	Delete(rcpt string) error
+}
+
+type AliasDB struct {
+	*sql.DB
+	dictpath string
+}
+
+func OpenAliasDB(name, dictpath string) (*AliasDB, error) {
+	db, err := sql.Open("sqlite3", name)
+	if err != nil {
+		return nil, err
+	}
+	stmt := `
+CREATE TABLE IF NOT EXISTS aliases (
+	recipient TEXT PRIMARY KEY,
+	destination TEXT NOT NULL,
+	expiry INTEGER,
+	note TEXT
+);`
+	_, err = db.Exec(stmt)
+	return &AliasDB{db, dictpath}, err
+}
+
+func (db *AliasDB) Create(dest 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)
+	}
+	return Alias{Recipient: rcpt, Destination: dest}, nil
+}
+
+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)
+	}
+	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 {
+			return aliases, err
+		}
+		if t.Valid {
+			a.Expiry = t.Time
+		}
+		if note.Valid {
+			a.Note = note.String
+		}
+		aliases = append(aliases, a)
+	}
+	return aliases, rows.Err()
+}
+
+func (db *AliasDB) Delete(rcpt string) error {
+	_, err := db.Exec("DELETE FROM aliases WHERE recipient = ?", rcpt)
+	if err != nil {
+		return fmt.Errorf("delete %s: %w", rcpt, err)
+	}
+	return nil
+}
blob - 81ef5523f4eb5c7954232f88156d459c9429bc3a
blob + 1e2d1a189ba11c0a2b3ceb7aaa37685ba5ee67e0
--- client.go
+++ client.go
@@ -5,8 +5,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
-
-	"mailmux.net/aliases"
 )
 
 const apiurl = "https://mailmux.net/v1/aliases"
@@ -15,13 +13,19 @@ const jsonContentType = "application/json"
 
 type Client struct {
 	*http.Client
-	addr  string
+	url   string
 	user  string
 	token string
 }
 
-func Dial(uri, user, token string) *Client {
-	return &Client{http.DefaultClient, uri, user, token}
+func Dial(addr, user, token string, tls bool) *Client {
+	var url string
+	if tls {
+		url = "https://" + addr
+	} else {
+		url = "http://" + addr
+	}
+	return &Client{http.DefaultClient, url, user, token}
 }
 
 func (c *Client) Register(username, password string) error {
@@ -35,7 +39,7 @@ func (c *Client) Register(username, password string) e
 	if err := json.NewEncoder(body).Encode(mcall); err != nil {
 		return fmt.Errorf("register %s: %v", username, err)
 	}
-	resp, err := c.Post(c.addr+"/register", jsonContentType, body)
+	resp, err := c.Post(c.url+"/register", jsonContentType, body)
 	if err != nil {
 		return fmt.Errorf("register %s: %v", username, err)
 	}
@@ -51,7 +55,7 @@ func (c *Client) Register(username, password string) e
 	return fmt.Errorf("register %s: %s", username, rerror.Error)
 }
 
-func (c *Client) NewAlias() (aliases.Aliases, error) {
+func (c *Client) NewAlias() ([]Alias, error) {
 	tmsg := &Mcall{
 		Type:     Tnew,
 		Username: c.user,
@@ -62,9 +66,9 @@ func (c *Client) NewAlias() (aliases.Aliases, error) {
 		return nil, fmt.Errorf("new alias: %w", err)
 	}
 
-	resp, err := http.Post(c.addr+"/aliases", jsonContentType, buf)
+	resp, err := http.Post(c.url+"/alias", jsonContentType, buf)
 	if err != nil {
-		return nil, fmt.Errorf("new alias: %w", err)
+		return nil, fmt.Errorf("new alias: POST tmsg: %w", err)
 	}
 	defer resp.Body.Close()
 	rmsg, err := ParseMcall(resp.Body)
@@ -77,7 +81,7 @@ func (c *Client) NewAlias() (aliases.Aliases, error) {
 	return rmsg.Aliases, nil
 }
 
-func (c *Client) Aliases() (aliases.Aliases, error) {
+func (c *Client) Aliases() ([]Alias, error) {
 	tmsg := &Mcall{
 		Type:     Tlist,
 		Username: c.user,
@@ -87,7 +91,7 @@ func (c *Client) Aliases() (aliases.Aliases, error) {
 	if err := json.NewEncoder(buf).Encode(tmsg); err != nil {
 		return nil, fmt.Errorf("list aliases: %w", err)
 	}
-	resp, err := http.Post(c.addr+"/aliases", jsonContentType, buf)
+	resp, err := http.Post(c.url+"/alias", jsonContentType, buf)
 	if err != nil {
 		return nil, fmt.Errorf("list aliases: %w", err)
 	}
blob - 5373d46e3aabb175b56ac070c0b75feb61afa2ce
blob + 22fa8c2a63d6e2df882e06396a8ae9ba2b367294
--- cmd/mailmux/mailmux.go
+++ cmd/mailmux/mailmux.go
@@ -1,10 +1,7 @@
 package main
 
 import (
-	"encoding/json"
-	"errors"
 	"fmt"
-	"io/fs"
 	"log"
 	"math/rand"
 	"net/http"
@@ -13,120 +10,12 @@ import (
 	"time"
 
 	mailmux "mailmux.net"
-	"mailmux.net/aliases"
 )
 
-type server struct {
-	seedfile  string
-	aliaspath string
-	aliases   aliases.Aliases
-	users     mailmux.UserStore
+func init() {
+	rand.Seed(time.Now().UnixNano())
 }
 
-func (srv *server) aliasHandler(w http.ResponseWriter, req *http.Request) {
-	tmsg, err := mailmux.ParseMcall(req.Body)
-	if err != nil {
-		rerror(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-
-	err = srv.users.Authenticate(tmsg.Username, mailmux.Password(tmsg.Password))
-	if err != nil {
-		rerror(w, "unauthorised", http.StatusUnauthorized)
-		log.Println(err)
-		return
-	}
-
-	switch tmsg.Type {
-	case mailmux.Tnew:
-		srv.newAlias(w, tmsg)
-		return
-	case mailmux.Tlist:
-		srv.listAliasHandler(w, tmsg)
-		return
-	}
-	rerror(w, "not implemented yet", http.StatusNotImplemented)
-}
-
-func (srv *server) listAliasHandler(w http.ResponseWriter, tmsg *mailmux.Mcall) {
-	filtered := make(aliases.Aliases)
-	for rcpt, dest := range srv.aliases {
-		if dest == tmsg.Username {
-			filtered[rcpt] = dest
-		}
-	}
-	rmsg := &mailmux.Mcall{
-		Type:     mailmux.Rlist,
-		Username: tmsg.Username,
-		Aliases:  filtered,
-	}
-	if err := json.NewEncoder(w).Encode(rmsg); err != nil {
-		rerror(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-}
-
-func (srv *server) newAlias(w http.ResponseWriter, tmsg *mailmux.Mcall) {
-	if tmsg.Type != mailmux.Tnew {
-		rerror(w, "message type is not Tnew", http.StatusBadRequest)
-		return
-	}
-
-	rcpt, err := mailmux.RandomUsername(srv.seedfile)
-	if err != nil {
-		s := fmt.Sprintf("random username: %v", err)
-		rerror(w, s, http.StatusInternalServerError)
-		return
-	}
-	srv.aliases[rcpt] = tmsg.Username
-	if err := aliases.Put(srv.aliases, srv.aliaspath); err != nil {
-		s := fmt.Sprintf("put: %v", err)
-		rerror(w, s, http.StatusInternalServerError)
-		return
-	}
-
-	rnew := &mailmux.Mcall{
-		Type:     mailmux.Rnew,
-		Username: tmsg.Username,
-		Aliases:  aliases.Aliases{rcpt: tmsg.Username},
-	}
-	if err := json.NewEncoder(w).Encode(rnew); err != nil {
-		rerror(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-}
-
-func (srv *server) deleteAlias(w http.ResponseWriter, req *http.Request) {
-	username := req.Form.Get("username")
-	recipient := req.Form.Get("recipient")
-	if recipient == "" {
-		http.Error(w, "empty recipient", http.StatusBadRequest)
-		return
-	}
-
-	for rcpt, dest := range srv.aliases {
-		if rcpt == recipient && dest == username {
-			delete(srv.aliases, rcpt)
-			w.WriteHeader(http.StatusNoContent)
-			return
-		}
-	}
-	msg := fmt.Sprintf("no alias for recpient %s with destination %s", recipient, username)
-	http.Error(w, msg, http.StatusNotFound)
-}
-
-// rerror replies to the HTTP request with a JSON-encoded Rerror message
-// with its Error field set to errormsg.
-// Just like http.Error, callers should ensure no further writes are done to w.
-func rerror(w http.ResponseWriter, errormsg string, status int) {
-	w.WriteHeader(status)
-	rmsg := &mailmux.Mcall{Type: mailmux.Rerror, Error: errormsg}
-	if err := json.NewEncoder(w).Encode(rmsg); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-}
-
 func main() {
 	cdir, err := os.UserCacheDir()
 	if err != nil {
@@ -138,32 +27,15 @@ func main() {
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
-
-	srv := &server{}
-	srv.users, err = mailmux.OpenUserDB(path.Join(cdir, "/mailmux/db"), ticketDir)
-	if errors.Is(err, fs.ErrNotExist) {
-		srv.users, err = mailmux.CreateUserDB(path.Join(cdir, "/mailmux/db"), ticketDir)
-	}
+	udb, err := mailmux.OpenUserDB(path.Join(cdir, "/mailmux/db"), ticketDir)
 	if err != nil {
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
-
-	srv.aliaspath = path.Join(cdir, "/mailmux/aliases")
-	srv.aliases, err = aliases.Load(srv.aliaspath)
-	if errors.Is(err, fs.ErrNotExist) {
-		_, err = os.Create(path.Join(cdir, "/mailmux/aliases"))
-	}
+	astore, err := mailmux.OpenAliasDB(path.Join(cdir, "/mailmux/db"), "/usr/share/dict/words")
 	if err != nil {
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
-
-	rand.Seed(time.Now().UnixNano())
-	srv.seedfile = "/usr/share/dict/words"
-
-	http.HandleFunc("/register", srv.registerHandler)
-	http.HandleFunc("/aliases", srv.aliasHandler)
-
-	log.Fatal(http.ListenAndServe("[::1]:6969", nil))
+	log.Fatal(http.ListenAndServe("[::1]:6969", mailmux.NewWebServer(astore, udb)))
 }
blob - 2050aa72e2ed4a6392495d6fed4365660326a41c
blob + 252dbce017446a93961926b7f8636ad5df4d5a4e
--- cmd/mailmux/mailmux_test.go
+++ cmd/mailmux/mailmux_test.go
@@ -8,46 +8,43 @@ import (
 	"testing"
 
 	mailmux "mailmux.net"
-	"mailmux.net/aliases"
 )
 
-func newTestServerClient(t *testing.T) *mailmux.Client {
+func newTestServer(t *testing.T) (net.Listener, *http.Server) {
 	dir, err := os.MkdirTemp("", "")
 	if err != nil {
 		t.Fatal(err)
 	}
-	db, err := mailmux.CreateUserDB(path.Join(dir, "db"), dir)
+	ticketDir := path.Join(dir, "/mailmux/ticket")
+	if err := os.MkdirAll(ticketDir, 0777); err != nil {
+		t.Fatal(err)
+	}
+	udb, err := mailmux.OpenUserDB(path.Join(dir, "/mailmux/db"), ticketDir)
 	if err != nil {
 		t.Fatal(err)
 	}
-	srv := &server{
-		users:     db,
-		aliases:   make(aliases.Aliases),
-		aliaspath: path.Join(dir, "aliases"),
-		seedfile:  "/usr/share/dict/words",
+	astore, err := mailmux.OpenAliasDB(path.Join(dir, "/mailmux/db"), "/usr/share/dict/words")
+	if err != nil {
+		t.Fatal(err)
 	}
-
 	ln, err := net.Listen("tcp", "[::1]:0")
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	mux := http.NewServeMux()
-	mux.HandleFunc("/register", srv.registerHandler)
-	mux.HandleFunc("/aliases", srv.aliasHandler)
-	go func() {
-		t.Fatal(http.Serve(ln, mux))
-	}()
-
-	client := mailmux.Dial("http://"+ln.Addr().String(), "test@example.com", "test")
-	if err := client.Register("test@example.com", "test"); err != nil {
-		t.Fatal(err)
+	return ln, &http.Server{
+		Addr:    ln.Addr().String(),
+		Handler: mailmux.NewWebServer(astore, udb),
 	}
-	return client
 }
 
 func TestBadRegistration(t *testing.T) {
-	client := newTestServerClient(t)
+	ln, srv := newTestServer(t)
+	defer srv.Close()
+	go func() {
+		srv.Serve(ln)
+	}()
+	client := mailmux.Dial(srv.Addr, "", "", false)
 	registrations := map[string]string{
 		"djfkjskdjf":      "dfjkdkfjsd",
 		"":                "asdfgjkl",
@@ -64,15 +61,24 @@ func TestBadRegistration(t *testing.T) {
 }
 
 func TestAliases(t *testing.T) {
-	client := newTestServerClient(t)
+	ln, srv := newTestServer(t)
+	go func() {
+		srv.Serve(ln)
+	}()
+	defer srv.Close()
+	client := mailmux.Dial(srv.Addr, "test@example.com", "secret", false)
+	if err := client.Register("test@example.com", "secret"); err != nil {
+		t.Fatal(err)
+	}
 	for i := 0; i <= 100; i++ {
 		_, err := client.NewAlias()
 		if err != nil {
 			t.Fatal(err)
 		}
 	}
-	_, err := client.Aliases()
+	a, err := client.Aliases()
 	if err != nil {
 		t.Fatal(err)
 	}
+	t.Log(a)
 }
blob - 9428a3263c8c7a06be0274eedf32b489c0ca49f3 (mode 644)
blob + /dev/null
--- cmd/mailmux/register.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"net/http"
-
-	mailmux "mailmux.net"
-)
-
-func (srv *server) registerUser(name string, pw mailmux.Password) error {
-	_, err := srv.users.Lookup(name)
-	if errors.Is(err, mailmux.ErrUnknownUser) {
-		return srv.users.Change(name, pw)
-	}
-	if err == nil {
-		return mailmux.ErrUserExist
-	}
-	return err
-}
-
-func (srv *server) registerHandler(w http.ResponseWriter, req *http.Request) {
-	if req.Method != http.MethodPost {
-		rerror(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
-		return
-	}
-
-	var tmsg mailmux.Mcall
-	if err := json.NewDecoder(req.Body).Decode(&tmsg); err != nil {
-		rerror(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-
-	if tmsg.Type != mailmux.Tregister {
-		s := fmt.Sprintf("mcall type %d is not Tregister", tmsg.Type)
-		rerror(w, s, http.StatusBadRequest)
-		return
-	}
-	if tmsg.Username == "" {
-		rerror(w, "empty username", http.StatusBadRequest)
-		return
-	}
-	if tmsg.Password == "" {
-		rerror(w, "empty password", http.StatusBadRequest)
-		return
-	}
-
-	err := srv.registerUser(tmsg.Username, mailmux.Password(tmsg.Password))
-	if err != nil {
-		rerror(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-}
blob - d6a73244b2364976cbef16028536aa644d35fb67
blob + 8c144c4624fd750edac385562d6df627ece50404
--- mcall.go
+++ mcall.go
@@ -5,8 +5,6 @@ import (
 	"errors"
 	"io"
 	"time"
-
-	"mailmux.net/aliases"
 )
 
 const (
@@ -28,11 +26,11 @@ const (
 // This design is loosely based on the Plan 9 network file protocol 9P.
 type Mcall struct {
 	Type     uint
-	Username string          `json:",omitempty"` // Tregister, Rregister
-	Password string          `json:",omitempty"` // Tregister
-	Error    string          `json:",omitempty"` // Rerror
-	Aliases  aliases.Aliases `json:",omitempty"` // Rnew, Rlist, Tremove
-	Expiry   time.Time       `json:",omitempty"` // Tnew, Rnew
+	Username string    `json:",omitempty"` // Tregister, Rregister
+	Password string    `json:",omitempty"` // Tregister
+	Error    string    `json:",omitempty"` // Rerror
+	Aliases  []Alias   `json:",omitempty"` // Rnew, Rlist, Tremove
+	Expiry   time.Time `json:",omitempty"` // Tnew, Rnew
 }
 
 // ParseMcall parses and validates a JSON-encoded Mcall from r.
blob - bb0b51d2b75a7b8148e02d22ae69b358e98d31db
blob + 0002ddd8efae18df9bafc950863b2331ca9e5d21
--- userdb.go
+++ userdb.go
@@ -4,7 +4,6 @@ import (
 	"database/sql"
 	"errors"
 	"fmt"
-	"io/fs"
 	"net/mail"
 	"os"
 	"path"
@@ -41,16 +40,17 @@ func CreateUserDB(name, ticketDir string) (*UserDB, er
 
 // OpenUserDB opens the named user database file and ticket directory.
 func OpenUserDB(name, dir string) (*UserDB, error) {
-	// sql.Open creates a blank file if it finds it doesn't exist.
-	// Return early if the file doesn't exist so we can handle it ourselves.
-	_, err := os.Stat(name)
-	if errors.Is(err, fs.ErrNotExist) {
-		return nil, err
-	}
 	db, err := sql.Open("sqlite3", name)
 	if err != nil {
 		return nil, err
 	}
+	stmt := `CREATE TABLE IF NOT EXISTS users (
+	username TEXT PRIMARY KEY,
+	password BLOB NOT NULL
+);`
+	if _, err := db.Exec(stmt); err != nil {
+		return nil, err
+	}
 	return &UserDB{db, dir}, db.Ping()
 }
 
blob - /dev/null
blob + d0d25d70ddf134d338c0dd36d983b041926a1a3c (mode 644)
--- /dev/null
+++ server.go
@@ -0,0 +1,125 @@
+package mailmux
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+)
+
+type Server struct {
+	aliases AliasStore
+	users   UserStore
+}
+
+// rerror replies to the HTTP request with a JSON-encoded Rerror message
+// with its Error field set to errormsg.
+// Just like http.Error, callers should ensure no further writes are done to w.
+func rerror(w http.ResponseWriter, errormsg string, status int) {
+	w.WriteHeader(status)
+	rmsg := &Mcall{Type: Rerror, Error: errormsg}
+	if err := json.NewEncoder(w).Encode(rmsg); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func NewWebServer(aliases AliasStore, users UserStore) http.Handler {
+	mux := http.NewServeMux()
+	srv := Server{aliases, users}
+	mux.HandleFunc("/register", srv.registerHandler)
+	mux.HandleFunc("/alias", srv.aliasHandler)
+	return mux
+}
+
+func (srv *Server) registerHandler(w http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		rerror(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+		return
+	}
+
+	defer req.Body.Close()
+	tmsg, err := ParseMcall(req.Body)
+	if err != nil {
+		rerror(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if tmsg.Type != Tregister {
+		s := fmt.Sprintf("mcall type %d is not Tregister", tmsg.Type)
+		rerror(w, s, http.StatusBadRequest)
+		return
+	}
+
+	_, err = srv.users.Lookup(tmsg.Username)
+	if err == nil {
+		rerror(w, "user already exists", http.StatusBadRequest)
+		return
+	} else if !errors.Is(err, ErrUnknownUser) {
+		rerror(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	if err := srv.users.Change(tmsg.Username, Password(tmsg.Password)); err != nil {
+		rerror(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	rmsg := &Mcall{
+		Type:     Rregister,
+		Username: tmsg.Username,
+	}
+	b, err := json.Marshal(rmsg)
+	if err != nil {
+		rerror(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Write(b)
+}
+
+func (srv *Server) aliasHandler(w http.ResponseWriter, req *http.Request) {
+	tmsg, err := ParseMcall(req.Body)
+	if err != nil {
+		rerror(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	err = srv.users.Authenticate(tmsg.Username, Password(tmsg.Password))
+	if err != nil {
+		rerror(w, "unauthorised", http.StatusUnauthorized)
+		log.Println(err)
+		return
+	}
+
+	var rmsg *Mcall
+	switch tmsg.Type {
+	case Tnew:
+		rmsg = srv.newAlias(tmsg)
+	case Tlist:
+		rmsg = srv.listAliasHandler(tmsg)
+	default:
+		rerror(w, "not implemented yet", http.StatusNotImplemented)
+		return
+	}
+	if rmsg.Type == Rerror {
+		w.WriteHeader(http.StatusInternalServerError)
+	}
+	if err := json.NewEncoder(w).Encode(rmsg); err != nil {
+		rerror(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+func (srv *Server) newAlias(tmsg *Mcall) *Mcall {
+	alias, err := srv.aliases.Create(tmsg.Username)
+	if err != nil {
+		return &Mcall{Type: Rerror, Error: err.Error()}
+	}
+	return &Mcall{Type: Rnew, 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: Rlist, Username: tmsg.Username, Aliases: a}
+}