Commit Diff


commit - cb474497f0adffac4fd2f79500e62f32bff27ea3
commit + 71bdede3e2286e895508fbbfe745eef880db6b7b
blob - 3e185bc025d1f54a717d41a63664fda8de1571cf
blob + 472454650f5c9083a8eceaee4c9fc1c41b0a1f5a
--- alias.go
+++ alias.go
@@ -11,23 +11,24 @@ import (
 // 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 
+	// 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
+	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
+	Expiry time.Time
 	// Note contains user-defined text that can be used to,
 	// for example, identify the alias.
-	Note        string
+	Note string
 }
 
 type AliasStore interface {
 	Create(dest string) (Alias, error)
+	Put(alias Alias) error
 	Aliases(dest string) ([]Alias, error)
 	Delete(rcpt string) error
 }
@@ -41,7 +42,7 @@ type AliasDB struct {
 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 
+// 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)
@@ -89,10 +90,11 @@ func (db *AliasDB) Put(a Alias) error {
 	var q string
 	if errors.Is(err, errRecipientNotExist) {
 		q = "INSERT INTO aliases (recipient, destination, expiry, note) VALUES (?, ?, ?, ?)"
+		_, err = db.Exec(q, a.Recipient, a.Destination, a.Expiry.Unix(), a.Note)
 	} else if err == nil {
-		q = "UPDATE aliases (recipient, destination, expiry, note) VALUES(?, ?, ?, ?)"
+		q = "UPDATE aliases SET recipient = ?, destination = ?, expiry = ?, note = ? WHERE recipient = ?"
+		_, err = db.Exec(q, a.Recipient, a.Destination, a.Expiry.Unix(), a.Note, a.Recipient)
 	}
-	_, err = db.Exec(q, a.Recipient, a.Destination, a.Expiry.Unix(), a.Note)
 	return err
 }
 
@@ -100,12 +102,14 @@ func (db *AliasDB) Put(a Alias) error {
 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)
+	var expiry int64
+	err := db.QueryRow(q, rcpt).Scan(&a.Recipient, &a.Destination, &expiry, &a.Note)
 	if errors.Is(err, sql.ErrNoRows) {
 		return Alias{}, errRecipientNotExist
 	} else if err != nil {
 		return Alias{}, err
 	}
+	a.Expiry = time.Unix(expiry, 0)
 	return a, nil
 }
 
blob - ea0912002994073ff2e272d13fde8a00e8cd538e
blob + 81a5c9082fd564ce701fcecde85b1b5b8ee760ce
--- client.go
+++ client.go
@@ -1,11 +1,10 @@
 package mailmux
 
 import (
-	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"net/http"
+	"net"
 )
 
 const apiurl = "https://mailmux.net/v1/aliases"
@@ -13,97 +12,82 @@ const apiurl = "https://mailmux.net/v1/aliases"
 const jsonContentType = "application/json"
 
 type Client struct {
-	*http.Client
-	url   string
-	user  string
-	token string
+	conn       net.Conn // or io.ReadWriteCloser?
 }
 
-func Dial(addr, user, token string, tls bool) *Client {
-	var url string
-	if tls {
-		url = "https://" + addr
-	} else {
-		url = "http://" + addr
+func Dial(network, address string) (*Client, error) {
+	c, err := net.Dial(network, address)
+	if err != nil {
+		return nil, err
 	}
-	return &Client{http.DefaultClient, url, user, token}
+	return &Client{conn: c}, nil
 }
 
-func (c *Client) Register(username, password string) error {
-	mcall := &Mcall{
-		Type:     Tregister,
-		Username: username,
-		Password: password,
+func (c *Client) exchange(tmsg *Mcall) (*Mcall, error) {
+	if err := c.tx(tmsg); err != nil {
+		return nil, fmt.Errorf("transmit tmsg: %w", err)
 	}
-
-	body := &bytes.Buffer{}
-	if err := json.NewEncoder(body).Encode(mcall); err != nil {
-		return fmt.Errorf("register %s: %v", username, err)
-	}
-	resp, err := c.Post(c.url+"/register", jsonContentType, body)
+	rmsg, err := c.rx()
 	if err != nil {
-		return fmt.Errorf("register %s: %v", username, err)
+		return nil, fmt.Errorf("receive rmsg: %w", err)
 	}
-	if resp.StatusCode == http.StatusOK {
-		return nil
-	}
+	// TODO sanity checks here.
+	// did we get back the message type we expected?
+	return rmsg, nil
+}
 
-	defer resp.Body.Close()
-	rerror, err := ParseMcall(resp.Body)
+func (c *Client) tx(tmsg *Mcall) error {
+	return json.NewEncoder(c.conn).Encode(tmsg)
+}
+
+func (c *Client) rx() (*Mcall, error) {
+	return ParseMcall(c.conn)
+}
+
+func (c *Client) Auth(username, password string) error {
+	tmsg := &Mcall{
+		Type:     Tauth,
+		Username: username,
+		Password: password,
+	}
+	rmsg, err := c.exchange(tmsg)
 	if err != nil {
-		return fmt.Errorf("register %s: parse response: %v", username, err)
+		return err
 	}
-	return fmt.Errorf("register %s: %s", username, rerror.Error)
+	if rmsg.Type == Rerror {
+		return errors.New(rmsg.Error)
+	}
+	return nil
 }
 
 func (c *Client) NewAlias() ([]Alias, error) {
 	tmsg := &Mcall{
-		Type:     Tcreate,
-		Username: c.user,
-		Password: c.token,
+		Type: Tcreate,
 	}
-	buf := &bytes.Buffer{}
-	if err := json.NewEncoder(buf).Encode(tmsg); err != nil {
-		return nil, fmt.Errorf("new alias: %w", err)
-	}
-
-	resp, err := http.Post(c.url+"/alias", jsonContentType, buf)
+	rmsg, err := c.exchange(tmsg)
 	if err != nil {
-		return nil, fmt.Errorf("new alias: POST tmsg: %w", err)
+		return nil, fmt.Errorf("exchange tmsg: %w", err)
 	}
-	defer resp.Body.Close()
-	rmsg, err := ParseMcall(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("new alias: parse response: %w", err)
-	}
 	if rmsg.Type == Rerror {
-		return nil, fmt.Errorf("new alias: %v", rmsg.Error)
+		return nil, errors.New(rmsg.Error)
 	}
 	return rmsg.Aliases, nil
 }
 
 func (c *Client) Aliases() ([]Alias, error) {
 	tmsg := &Mcall{
-		Type:     Tlist,
-		Username: c.user,
-		Password: c.token,
+		Type: Tlist,
 	}
-	buf := &bytes.Buffer{}
-	if err := json.NewEncoder(buf).Encode(tmsg); err != nil {
-		return nil, fmt.Errorf("encode tmsg: %w", err)
-	}
-	resp, err := http.Post(c.url+"/alias", jsonContentType, buf)
+	rmsg, err := c.exchange(tmsg)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("exchange tmsg: %w", err)
 	}
-
-	defer resp.Body.Close()
-	rmsg, err := ParseMcall(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("parse response: %w", err)
-	}
 	if rmsg.Type == Rerror {
 		return nil, errors.New(rmsg.Error)
 	}
 	return rmsg.Aliases, nil
 }
+
+func (c *Client) Close() error {
+	return c.conn.Close()
+}
blob - faa7d1977d89b20452eabe3d25e5d493f78795f4 (mode 644)
blob + /dev/null
--- cmd/mailmux/mailmux_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package main
-
-import (
-	"net"
-	"net/http"
-	"os"
-	"path"
-	"testing"
-
-	mailmux "mailmux.net"
-)
-
-func newTestServer(t *testing.T) (net.Listener, *http.Server) {
-	dir, err := os.MkdirTemp("", "")
-	if err != nil {
-		t.Fatal(err)
-	}
-	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)
-	}
-	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)
-	}
-
-	return ln, &http.Server{
-		Addr:    ln.Addr().String(),
-		Handler: mailmux.NewWebServer(astore, udb),
-	}
-}
-
-func TestBadRegistration(t *testing.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",
-		"fjdklskjdsf":     "",
-		"@@@@":            "dfjksjkdf",
-		"foo@example.com": "",
-	}
-	for username, password := range registrations {
-		err := client.Register(username, password)
-		if err == nil {
-			t.Errorf("nil error on bad registration username %q password %q", username, password)
-		}
-	}
-}
-
-func TestAliases(t *testing.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)
-		}
-	}
-	a, err := client.Aliases()
-	if err != nil {
-		t.Fatal("list aliases:", err)
-	}
-	t.Log(a)
-}
blob - /dev/null
blob + 60fbf28f01990ed5518b5a8ed2f90a83f5f8466a (mode 644)
--- /dev/null
+++ http.go
@@ -0,0 +1,195 @@
+package mailmux
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+)
+
+func jerror(w http.ResponseWriter, msg string, status int) {
+	w.WriteHeader(status)
+	m := map[string]string{"error": msg}
+	json.NewEncoder(w).Encode(m)
+}
+
+func NewWebServer(aliases AliasStore, users UserStore) http.Handler {
+	mux := http.NewServeMux()
+	aliaseshandler := &aliasesHandler{aliases}
+	aliashandler := &aliasHandler{aliases}
+	authhandler := &authHandler{users}
+	mux.Handle("/v1/register", authhandler)
+	mux.Handle("/v1/aliases", authhandler.basicAuth(aliaseshandler))
+	mux.Handle("/v1/aliases/", authhandler.basicAuth(aliashandler))
+	return mux
+}
+
+type aliasesHandler struct {
+	AliasStore
+}
+
+func (h *aliasesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	username, _, ok := req.BasicAuth()
+	if !ok || len(username) == 0 {
+		jerror(w, "empty username", http.StatusForbidden)
+		return
+	}
+
+	switch req.Method {
+	case http.MethodGet:
+		aliases, err := h.Aliases(username)
+		if err != nil {
+			jerror(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		json.NewEncoder(w).Encode(aliases)
+
+	case http.MethodPost:
+		alias, err := h.Create(username)
+		if err != nil {
+			jerror(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		json.NewEncoder(w).Encode(alias)
+	default:
+		jerror(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
+	}
+}
+
+type aliasHandler struct {
+	AliasStore
+}
+
+func (h *aliasHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	username, _, ok := req.BasicAuth()
+	if !ok || len(username) == 0 {
+		jerror(w, "empty username", http.StatusForbidden)
+		return
+	}
+
+	recipient := path.Base(req.URL.Path)
+	aliases, err := h.Aliases(username)
+	if err != nil {
+		jerror(w, fmt.Sprintf("aliases for %s: %v", recipient, err), http.StatusInternalServerError)
+		return
+	}
+	var alias Alias
+	var found bool
+	for _, a := range aliases {
+		if a.Recipient == recipient {
+			alias = a
+			found = true
+		}
+	}
+	if !found {
+		jerror(w, "no such alias", http.StatusNotFound)
+		return
+	}
+
+	switch req.Method {
+	case http.MethodDelete:
+		if err := h.Delete(recipient); err != nil {
+			jerror(w, fmt.Sprintf("delete %s: %v", recipient, err), http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(http.StatusNoContent)
+
+	case http.MethodGet:
+		json.NewEncoder(w).Encode(alias)
+
+	case http.MethodPost:
+		if err := req.ParseForm(); err != nil {
+			jerror(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
+			return
+		}
+		for param := range req.PostForm {
+			switch param {
+			case "expiry":
+				i, err := strconv.Atoi(req.PostForm.Get(param))
+				if err != nil {
+					jerror(w, fmt.Sprintf("parse expiry: %v", err), http.StatusBadRequest)
+					return
+				}
+				alias.Expiry = time.Unix(int64(i), 0)
+			case "note":
+				alias.Note = req.PostForm.Get(param)
+			default:
+				jerror(w, fmt.Sprintf("invalid alias parameter %s", param), http.StatusBadRequest)
+				return
+			}
+		}
+		if err := h.Put(alias); err != nil {
+			jerror(w, fmt.Sprintf("update alias %s: %v", alias.Recipient, err), http.StatusInternalServerError)
+			return
+		}
+		json.NewEncoder(w).Encode(alias)
+
+	default:
+		jerror(w, "not implemented yet", http.StatusMethodNotAllowed)
+	}
+}
+
+type authHandler struct {
+	UserStore
+}
+
+func (h *authHandler) basicAuth(next http.Handler) http.Handler {
+	fn := func(w http.ResponseWriter, req *http.Request) {
+		username, password, ok := req.BasicAuth()
+		if !ok || len(username) == 0 || len(password) == 0 {
+			jerror(w, "unauthorised", http.StatusUnauthorized)
+			return
+		}
+		err := h.Authenticate(username, Password(password))
+		if err != nil {
+			jerror(w, "unauthorised", http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, req)
+	}
+	return http.HandlerFunc(fn)
+}
+
+func (h *authHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		code := http.StatusMethodNotAllowed
+		http.Error(w, http.StatusText(code), code)
+		return
+	}
+
+	if err := req.ParseForm(); err != nil {
+		jerror(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	username := req.PostForm.Get("username")
+	if username == "" {
+		jerror(w, "empty username", http.StatusBadRequest)
+		return
+	}
+	password := req.PostForm.Get("password")
+	if password == "" {
+		jerror(w, "empty password", http.StatusBadRequest)
+		return
+	}
+
+	_, err := h.Lookup(username)
+	if err == nil {
+		jerror(w, "user already exists", http.StatusBadRequest)
+		return
+	} else if !errors.Is(err, ErrUnknownUser) {
+		jerror(w, fmt.Sprintf("lookup %s: %v", username, err), http.StatusInternalServerError)
+		return
+	}
+
+	if err := h.Change(username, Password(password)); err != nil {
+		code := http.StatusInternalServerError
+		if strings.Contains(err.Error(), "invalid username") {
+			code = http.StatusBadRequest
+		}
+		jerror(w, fmt.Sprintf("change %s: %v", username, err), code)
+	}
+}
blob - 275add587c519278a2643e428b56045b6021b10a
blob + 0e35e4f50d9c51bb7681dbef07f96d781d25bd58
--- mcall.go
+++ mcall.go
@@ -17,6 +17,8 @@ const (
 	Rlist
 	Tremove
 	Rremove
+	Tauth
+	Rauth
 )
 
 // Mcall represents a message passed between mailmux clients and servers.
@@ -45,12 +47,26 @@ func ParseMcall(r io.Reader) (*Mcall, error) {
 		if mc.Error == "" {
 			return nil, errors.New("empty error message")
 		}
-	case Tregister, Tcreate, Tlist, Tremove:
+	case Tauth:
 		if mc.Username == "" {
 			return nil, errors.New("empty username")
-		} else if mc.Password == "" {
+		}
+		if mc.Password == "" {
 			return nil, errors.New("empty password")
 		}
+	case Tregister, Tcreate, Tlist, Tremove:
+		// check required params
 	}
 	return &mc, nil
 }
+
+func writeMcall(w io.Writer, mcall *Mcall) error {
+	return json.NewEncoder(w).Encode(mcall)
+}
+
+// rerror writes a JSON-encoded Rerror message to the underlying writer
+// with its Error field set to errormsg.
+func rerror(w io.Writer, errormsg string) error {
+	rmsg := &Mcall{Type: Rerror, Error: errormsg}
+	return writeMcall(w, rmsg)
+}
blob - /dev/null
blob + 59e531ef8bd789f0abf90ef96364a897fb031ec3 (mode 644)
--- /dev/null
+++ http_test.go
@@ -0,0 +1,125 @@
+package mailmux
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestBadRegister(t *testing.T) {
+	srv := newTestServer()
+	httpsrv := httptest.NewServer(NewWebServer(srv.Aliases, srv.Users))
+	client := httpsrv.Client()
+
+	registrations := []url.Values{
+		// this user already exists; created in newTestServer.
+		url.Values{
+			"username": []string{testUsername},
+			"password": []string{testPassword},
+		},
+		// username isn't an email address
+		url.Values{
+			"username": []string{"bla bla hello world!"},
+			"password": []string{testPassword},
+		},
+		// empty password
+		url.Values{
+			"username": []string{testUsername},
+			"password": []string{},
+		},
+	}
+	for i, form := range registrations {
+		resp, err := client.PostForm(httpsrv.URL+"/v1/register", form)
+		if err != nil {
+			t.Error(err)
+		}
+		if resp.StatusCode != http.StatusBadRequest {
+			t.Errorf("bad registration case %d got HTTP status %s", i, resp.Status)
+			b, err := io.ReadAll(resp.Body)
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Log(string(b))
+		}
+	}
+}
+
+// TestAliasLifecycle tests creating, reading, updating then deleting an alias.
+func TestAliasLifecycle(t *testing.T) {
+	srv := newTestServer()
+	httpsrv := httptest.NewServer(NewWebServer(srv.Aliases, srv.Users))
+	client := httpsrv.Client()
+
+	req, err := http.NewRequest(http.MethodPost, httpsrv.URL+"/v1/aliases", nil)
+	req.SetBasicAuth(testUsername, testPassword)
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatalf("create alias: %v", err)
+	}
+	defer resp.Body.Close()
+	var alias Alias
+	if err := json.NewDecoder(resp.Body).Decode(&alias); err != nil {
+		t.Fatalf("decode new alias response: %v", err)
+	}
+
+	req, err = http.NewRequest(http.MethodGet, httpsrv.URL+"/v1/aliases", nil)
+	req.SetBasicAuth(testUsername, testPassword)
+	resp, err = client.Do(req)
+	if err != nil {
+		t.Fatalf("list aliases: %v", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		b, _ := io.ReadAll(resp.Body)
+		t.Log(string(b))
+		t.Fatalf("list aliases: HTTP status %s", resp.Status)
+	}
+	var aliases []Alias
+	if err := json.NewDecoder(resp.Body).Decode(&aliases); err != nil {
+		t.Fatalf("decode list aliases response: %v", err)
+	}
+
+	// update alias by adding a note and expiry time
+	v := make(url.Values)
+	v.Set("expiry", strconv.Itoa(int(time.Now().Add(time.Hour).Unix())))
+	v.Set("note", "a test note to describe something")
+	req, err = http.NewRequest(http.MethodPost, httpsrv.URL+"/v1/aliases/"+alias.Recipient, strings.NewReader(v.Encode()))
+	if err != nil {
+		t.Fatalf("create update request: %v", err)
+	}
+	req.SetBasicAuth(testUsername, testPassword)
+	resp, err = client.Do(req)
+	if err != nil {
+		t.Fatalf("update alias: %v", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		b, _ := io.ReadAll(resp.Body)
+		t.Log(string(b))
+		t.Fatalf("update alias: HTTP status %s", resp.Status)
+	}
+	defer resp.Body.Close()
+	if err := json.NewDecoder(resp.Body).Decode(&alias); err != nil {
+		t.Fatalf("decode updated alias response: %v", err)
+	}
+
+	req, err = http.NewRequest(http.MethodDelete, httpsrv.URL+"/v1/aliases/"+alias.Recipient, nil)
+	if err != nil {
+		t.Fatalf("create delete request: %v", err)
+	}
+	req.SetBasicAuth(testUsername, testPassword)
+	resp, err = client.Do(req)
+	if err != nil {
+		t.Fatalf("delete alias: %v", err)
+	}
+	if resp.StatusCode != http.StatusNoContent {
+		b, _ := io.ReadAll(resp.Body)
+		t.Log(string(b))
+		t.Fatalf("delete alias: HTTP status %s", resp.Status)
+	}
+}
blob - 50982ee30965a523551740de2e702c7df1bd933b
blob + 9f38fd5a7b217257cdd6f38fc474ae7326696321
--- server.go
+++ server.go
@@ -1,125 +1,99 @@
 package mailmux
 
 import (
-	"encoding/json"
 	"errors"
 	"fmt"
-	"log"
-	"net/http"
+	"io"
+	"net"
+	"os"
 )
 
 type Server struct {
-	aliases AliasStore
-	users   UserStore
+	ln      net.Listener
+	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 (srv *Server) ListenAndServe() error {
+	if srv.ln == nil {
+		return errors.New("nil listener")
 	}
+	return srv.Serve(srv.ln)
 }
 
-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
+func (srv *Server) Serve(ln net.Listener) error {
+	if ln == nil {
+		return errors.New("nil listener")
 	}
-
-	defer req.Body.Close()
-	tmsg, err := ParseMcall(req.Body)
-	if err != nil {
-		rerror(w, err.Error(), http.StatusBadRequest)
-		return
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			return err
+		}
+		go func() {
+			if err := srv.handleConn(conn); err != nil {
+				fmt.Fprintln(os.Stderr, err)
+			}
+			conn.Close()
+		}()
 	}
-	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)
+	return errors.New("unreachable?")
 }
 
-func (srv *Server) aliasHandler(w http.ResponseWriter, req *http.Request) {
-	tmsg, err := ParseMcall(req.Body)
+func (srv *Server) handleConn(conn net.Conn) error {
+	tmsg, err := ParseMcall(conn)
 	if err != nil {
-		rerror(w, err.Error(), http.StatusBadRequest)
-		return
+		err = fmt.Errorf("parse mcall: %w", err)
+		rerror(conn, err.Error())
+		return err
 	}
-
-	err = srv.users.Authenticate(tmsg.Username, Password(tmsg.Password))
-	if err != nil {
-		rerror(w, "unauthorised", http.StatusUnauthorized)
-		log.Println(err)
-		return
+	if tmsg.Type != Tauth {
+		rerror(conn, "unauthorised")
+		return nil
 	}
+	if err := srv.Users.Authenticate(tmsg.Username, Password(tmsg.Password)); err != nil {
+		rerror(conn, "authentication failed")
+		return err
+	}
+	rmsg := &Mcall{Type: Rauth, Username: tmsg.Username}
+	writeMcall(conn, rmsg)
 
-	var rmsg *Mcall
-	switch tmsg.Type {
-	case Tcreate:
-		rmsg = srv.newAlias(tmsg)
-	case Tlist:
-		rmsg = srv.listAliasHandler(tmsg)
-	default:
-		rerror(w, "not implemented yet", http.StatusNotImplemented)
-		return
+	user := tmsg.Username
+	for {
+		tmsg, err := ParseMcall(conn)
+		if errors.Is(err, io.EOF) {
+			return nil
+		} else if err != nil {
+			rerror(conn, err.Error())
+			continue
+		}
+		rmsg := &Mcall{}
+		switch tmsg.Type {
+		case Tauth:
+			rmsg = &Mcall{Type: Rerror, Error: "already authenticated"}
+		case Tlist:
+			rmsg = srv.listAliases(user)
+		case Tcreate:
+			rmsg = srv.createAlias(user)
+		default:
+			rmsg = &Mcall{Type: Rerror, Error: "this tmessage not implemented yet"}
+		}
+		writeMcall(conn, rmsg)
 	}
-	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)
+func (srv *Server) createAlias(username string) *Mcall {
+	alias, err := srv.Aliases.Create(username)
 	if err != nil {
 		return &Mcall{Type: Rerror, Error: err.Error()}
 	}
-	return &Mcall{Type: Rcreate, Username: tmsg.Username, Aliases: []Alias{alias}}
+	return &Mcall{Type: Rcreate, Aliases: []Alias{alias}}
 }
 
-func (srv *Server) listAliasHandler(tmsg *Mcall) *Mcall {
-	a, err := srv.aliases.Aliases(tmsg.Username)
+func (srv *Server) listAliases(username string) *Mcall {
+	a, err := srv.Aliases.Aliases(username)
 	if err != nil {
-		return &Mcall{Type: Rerror, Error: fmt.Sprintf("aliases for %s: %v", tmsg.Username, err)}
+		return &Mcall{Type: Rerror, Error: fmt.Sprintf("aliases for %s: %v", username, err)}
 	}
-	return &Mcall{Type: Rlist, Username: tmsg.Username, Aliases: a}
+	return &Mcall{Type: Rlist, Aliases: a}
 }
blob - 0002ddd8efae18df9bafc950863b2331ca9e5d21
blob + ec4e56c45297dc213a48d61aca6edde0232b8781
--- userdb.go
+++ userdb.go
@@ -68,9 +68,9 @@ func (db *UserDB) Lookup(name string) (User, error) {
 	row := db.QueryRow("SELECT username, password FROM users WHERE username = ?", name)
 	if err := row.Scan(&u.name, &u.password); err != nil {
 		if errors.Is(err, sql.ErrNoRows) {
-			return User{}, fmt.Errorf("lookup %s: %w", name, ErrUnknownUser)
+			return User{}, ErrUnknownUser
 		}
-		return User{}, fmt.Errorf("lookup %s: %w", name, err)
+		return User{}, err
 	}
 	return u, nil
 }
@@ -104,18 +104,15 @@ func (db *UserDB) Change(name string, new Password) er
 		return db.add(name, new)
 	}
 	if err != nil {
-		return fmt.Errorf("change %s: %w", name, err)
+		return fmt.Errorf("lookup %s: %w", name, err)
 	}
 
 	hashed, err := bcrypt.GenerateFromPassword(new, bcrypt.DefaultCost)
 	if err != nil {
-		return fmt.Errorf("change %s: %w", name, err)
+		return fmt.Errorf("generate hash: %w", err)
 	}
 	_, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", hashed, name)
-	if err != nil {
-		return fmt.Errorf("change %s: %w", name, err)
-	}
-	return nil
+	return err
 }
 
 func (db *UserDB) Delete(name string) error {
blob - /dev/null
blob + 5119d0d2235a5e52a418398ab4bc02496bf28174 (mode 644)
--- /dev/null
+++ server_test.go
@@ -0,0 +1,68 @@
+package mailmux
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"os"
+	"testing"
+)
+
+const (
+	testUsername = "test@example.com"
+	testPassword = "dummy"
+)
+
+func newTestServer() *Server {
+	tmpdb, err := os.CreateTemp("", "")
+	if err != nil {
+		panic(err)
+	}
+	aliasdb, err := OpenAliasDB(tmpdb.Name(), "/usr/share/dict/words")
+	if err != nil {
+		panic(err)
+	}
+	userdb, err := OpenUserDB(tmpdb.Name(), os.TempDir())
+	if err != nil {
+		panic(err)
+	}
+	if err := userdb.Change(testUsername, Password(testPassword)); err != nil {
+		panic(err)
+	}
+	return &Server{
+		Aliases: aliasdb,
+		Users:   userdb,
+	}
+}
+
+func TestBasicList(t *testing.T) {
+	srv := newTestServer()
+	ln, err := net.Listen("unix", "/tmp/test.sock")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ln.Close()
+	go func() {
+		err := srv.Serve(ln)
+		if err != nil && !errors.Is(err, net.ErrClosed) {
+			t.Fatal(err)
+		}
+	}()
+	client, err := Dial("unix", "/tmp/test.sock")
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = client.Auth("test@example.com", "dummy")
+	if err != nil {
+		t.Fatal(err)
+	}
+	a, err := client.NewAlias()
+	if err != nil {
+		t.Error(err)
+	}
+	a, err = client.Aliases()
+	if err != nil {
+		t.Log(err)
+	}
+	fmt.Println(a)
+}