Commit Diff


commit - 1fdf07b79b193a5a1caf7d939f36b824a13086f4
commit + 5e70998a1d4f415458485d8c6f7f85ba82dc42b3
blob - 044b38889b0da1ab6bcf5d50dda1ccb7762c6dcc
blob + 85add4b7a714a0bd3b1e17208a80dbeac77d655a
--- main.go
+++ main.go
@@ -1,91 +1,35 @@
-package main
+package mailmux
 
 import (
-	"bufio"
-	"errors"
 	"fmt"
-	"io"
 	"math/rand"
 	"os"
-	"strings"
 	"time"
+
+	"mailmux.net/aliases"
 )
 
-func randomLine(f *os.File) (string, error)  {
-	fi, err := f.Stat()
-	if err != nil {
-		return "", err
-	}
-	offset := rand.Int63n(fi.Size())
-	offset, err = f.Seek(offset, io.SeekStart)
-	if err != nil {
-		return "", err
-	}
-
-	br := bufio.NewReader(f)
-	for {
-		b, err := br.ReadByte()
-		if b == '\n' {
-			break
-		}
-		if err != nil {
-			return "", err
-		}
-	}
-	line, err := br.ReadString('\n')
-	if errors.Is(err, io.EOF) {
-		// the file ends without a newline - no problem
-	} else if err != nil {
-		return "", err
-	}
-
-	line = strings.TrimSpace(line)
-	if line == "" {
-		// empty line. we're either at the end or hit a blank line. try again
-		return randomLine(f)
-	}
-	return line, nil
-}
-
-func randomAddr(dictpath string, domain string) (string, error) {
-	f, err := os.Open(dictpath)
-	if err != nil {
-		return "", fmt.Errorf("open dictionary: %w", err)
-	}
-	defer f.Close()
-
-	first, err := randomLine(f)
-	if err != nil {
-		return "", fmt.Errorf("first random word: %w", err)
-	}
-	first = strings.ToLower(first)
-	second, err := randomLine(f)
-	if err != nil {
-		return "", fmt.Errorf("second random word: %w", err)
-	}
-	second = strings.ToLower(second)
-
-	return fmt.Sprintf("%s_%s%02d@%s", first, second, rand.Intn(99), domain), nil
-}
-
 func init() {
 	rand.Seed(time.Now().UnixNano())
 }
 
-const usage string = "usage: randomalias target ..."
+const usage string = "usage: randomalias destination"
 
 func main() {
 	if len(os.Args) < 2 {
 		fmt.Println(usage)
 		os.Exit(1)
 	}
-	targets := os.Args[1:]
-	address, err := randomAddr("/usr/share/dict/words", "mailmux.net")
+	dest := os.Args[1]
+	username, err := randomUsername("/usr/share/dict/words")
 	if err != nil {
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
-	for _, targ := range targets {
-		fmt.Printf("%s: %s\n", address, targ)
+	m := make(aliases.Aliases)
+	m[username] = dest
+	if err := aliases.NewEncoder(os.Stdout).Encode(m); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
 	}
 }
blob - /dev/null
blob + cb99ad6c6929cf74bc2f830add5f7eeff78cdb2c (mode 644)
--- /dev/null
+++ aliases/aliases.go
@@ -0,0 +1,104 @@
+// package aliases provides encoding and decoding of Unix alias(5) files
+// read by systems like Postfix and OpenBSD's smtpd(8).
+package aliases
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+)
+
+type Aliases map[string]string
+
+type Encoder struct {
+	w *bufio.Writer
+}
+
+func NewEncoder(w io.Writer) *Encoder {
+	return &Encoder{bufio.NewWriter(w)}
+}
+
+func (e *Encoder) Encode(m Aliases) error {
+	defer e.w.Flush()
+	for rcpt, dest := range m {
+		_, err := e.w.WriteString(fmt.Sprintf("%s: %s\n", rcpt, dest))
+		if err != nil {
+			return fmt.Errorf("write alias %s: %w", rcpt, err)
+		}
+	}
+	return nil
+}
+
+func Parse(r io.Reader) (Aliases, error) {
+	m := make(Aliases)
+	sc := bufio.NewScanner(r)
+	for sc.Scan() {
+		line := strings.TrimSpace(sc.Text())
+		if strings.HasPrefix(line, "#") {
+			continue // skip comments
+		}
+		if line == "" {
+			continue // skip blank lines
+		}
+
+		rcpt, dest, err := parseLine(line)
+		if err != nil {
+			return nil, fmt.Errorf("parse: %w", err)
+		}
+		m[rcpt] = dest
+	}
+	if sc.Err() != nil {
+		return nil, fmt.Errorf("parse: %w", sc.Err())
+	}
+	return m, nil
+}
+
+// Load reads and returns named aliases file.
+func Load(name string) (Aliases, error) {
+	f, err := os.Open(name)
+	if err != nil {
+		return nil, fmt.Errorf("load: %w", err)
+	}
+	defer f.Close()
+	return Parse(f)
+}
+
+// Put writes aliases to the named file.
+// The file is truncated if it already exists, otherwise it is created.
+func Put(m Aliases, name string) error {
+	f, err := os.Create(name)
+	if err != nil {
+		return fmt.Errorf("put: %w", err)
+	}
+	defer f.Close()
+	if err := NewEncoder(f).Encode(m); err != nil {
+		return fmt.Errorf("put: %w", err)
+	}
+	return nil
+}
+
+// parseLine parses a line of the form "foo: bar@example.com", returning two fields:
+// the recipient (foo) and destination (bar@example.com).
+func parseLine(s string) (recipient, destination string, err error) {
+	a := strings.Fields(s)
+	if len(a) > 2 {
+		return "", "", fmt.Errorf("parse line: too many fields")
+	} else if len(a) < 2 {
+		return "", "", fmt.Errorf("parse line: too few fields")
+	}
+
+	recipient, destination = strings.TrimSuffix(a[0], ":"), a[1]
+	if !strings.HasSuffix(a[0], ":") {
+		return "", "", fmt.Errorf("parse line: invalid recipient: expected %q, got %q", ":", recipient[len(recipient)])
+	}
+	if recipient == "" {
+		return "", "", errors.New("parse line: empty recipient")
+	}
+	if destination == "" {
+		return "", "", errors.New("parse line: empty destination")
+	}
+	return recipient, destination, nil
+}
blob - /dev/null
blob + d95ee11b0929b2cf4872d3e292fe413c016d8f49 (mode 644)
--- /dev/null
+++ aliases/aliases_test.go
@@ -0,0 +1,38 @@
+package aliases
+
+import (
+	"io"
+	"strings"
+	"testing"
+)
+
+func TestParse(t *testing.T) {
+	var r io.Reader
+	valid := map[string]string{
+		"foo: bar@example.com": "parse valid line",
+		"# some comment": "parse valid commented line",
+		"    ": "parse empty line",
+	}
+	for line, msg := range valid {
+		r = strings.NewReader(line)
+		_, err := Parse(r)
+		if err != nil {
+			t.Errorf("%s %q: %v", msg, line, err)
+		}
+	}
+
+	invalid := map[string]string{
+		"oops:": "missing destination",
+		"  : asdfhjkl": "missing recipient",
+		":": "missing recipient and destination",
+		"::: alex@example.com": "too many colons",
+		"1 2 3 4 5": "too many fields",
+	}
+	for line, problem := range invalid {
+		r = strings.NewReader(line)
+		_, err := Parse(r)
+		if err == nil {
+			t.Errorf("no error parsing %q (%s)", line, problem)
+		}
+	}
+}
\ No newline at end of file
blob - /dev/null
blob + 209399933ec9d08d05107ae2b620f31184b0a393 (mode 644)
--- /dev/null
+++ client.go
@@ -0,0 +1,48 @@
+package mailmux
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+)
+
+const apiurl = "https://mailmux.net/v1/aliases"
+
+type Client struct {
+	*http.Client
+	user string
+	token string
+}
+
+func NewClient(user, token string) *Client {
+	return &Client{http.DefaultClient, user, token}
+}
+
+func (c *Client) NewAlias() (Alias, error) {
+	v := url.Values{}
+	v.Add("user", c.user)
+	v.Add("token", c.token)
+	resp, err := http.PostForm(apiurl, v)
+	if err != nil {
+		return Alias{}, err
+	}
+	defer resp.Body.Close()
+	return Alias{}, nil
+}
+
+func (c *Client) Aliases() ([]Alias, error) {
+	v := url.Values{}
+	v.Add("user", c.user)
+	v.Add("token", c.token)
+	req, err := http.NewRequest(http.MethodGet, apiurl, nil)
+	if err != nil {
+		return nil, fmt.Errorf("list aliases: %w", err)
+	}
+	req.URL.RawQuery = v.Encode()
+	resp, err := c.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("list aliases: %w", err)
+	}
+	defer resp.Body.Close()
+	return nil, nil
+}
blob - /dev/null
blob + 1cd07436a3759ef0d35ff6eb5dbb6b2afbfe336a (mode 644)
--- /dev/null
+++ go.mod
@@ -0,0 +1,5 @@
+module mailmux.net
+
+go 1.17
+
+require github.com/mattn/go-sqlite3 v1.14.12 // indirect
blob - /dev/null
blob + a7151d6366cd2432431a58124bb54ea304dd7bf2 (mode 644)
--- /dev/null
+++ go.sum
@@ -0,0 +1,2 @@
+github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
+github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
blob - /dev/null
blob + 728c2ccabade3732ca545a7132d71bf0e3b4cf2c (mode 644)
--- /dev/null
+++ init.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS users (
+	username TEXT PRIMARY KEY,
+	password BLOB NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS aliases (
+	recipient TEXT PRIMARY KEY,
+	destination TEXT,
+	FOREIGN KEY(destination) REFERENCES users(username)
+)
\ No newline at end of file
blob - /dev/null
blob + 58b412721460b8163b1178c27a65f0e8438eb51c (mode 644)
--- /dev/null
+++ mailmux.go
@@ -0,0 +1,6 @@
+package mailmux
+
+type Alias struct {
+	recipient string
+	destination string
+}
blob - /dev/null
blob + ebd59c328ae9e25be1ffc97a44ea3aaa8e62de1e (mode 644)
--- /dev/null
+++ random.go
@@ -0,0 +1,68 @@
+package mailmux
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand"
+	"os"
+	"strings"
+)
+
+func randomLine(f *os.File) (string, error) {
+	fi, err := f.Stat()
+	if err != nil {
+		return "", err
+	}
+	offset := rand.Int63n(fi.Size())
+	offset, err = f.Seek(offset, io.SeekStart)
+	if err != nil {
+		return "", err
+	}
+
+	br := bufio.NewReader(f)
+	for {
+		b, err := br.ReadByte()
+		if b == '\n' {
+			break
+		}
+		if err != nil {
+			return "", err
+		}
+	}
+	line, err := br.ReadString('\n')
+	if errors.Is(err, io.EOF) {
+		// the file ends without a newline - no problem
+	} else if err != nil {
+		return "", err
+	}
+
+	line = strings.TrimSpace(line)
+	if line == "" {
+		// empty line. we're either at the end or hit a blank line. try again
+		return randomLine(f)
+	}
+	return line, nil
+}
+
+func randomUsername(dictpath string) (string, error) {
+	f, err := os.Open(dictpath)
+	if err != nil {
+		return "", fmt.Errorf("open dictionary: %w", err)
+	}
+	defer f.Close()
+
+	first, err := randomLine(f)
+	if err != nil {
+		return "", fmt.Errorf("first random word: %w", err)
+	}
+	first = strings.ToLower(first)
+	second, err := randomLine(f)
+	if err != nil {
+		return "", fmt.Errorf("second random word: %w", err)
+	}
+	second = strings.ToLower(second)
+
+	return fmt.Sprintf("%s_%s%02d", first, second, rand.Intn(99)), nil
+}
blob - /dev/null
blob + 1497bc5a1f39bd6dc7ebb079808e3cf6e2462799 (mode 644)
--- /dev/null
+++ random_test.go
@@ -0,0 +1,10 @@
+package mailmux
+
+import "testing"
+
+func TestRandomAlias(t *testing.T) {
+	_, err := randomUsername("/usr/share/dict/words")
+	if err != nil {
+		t.Error(err)
+	}
+}
blob - /dev/null
blob + b6e3281a1a144cb517529cf376cc9e2ff3232613 (mode 644)
--- /dev/null
+++ server.go
@@ -0,0 +1,6 @@
+package mailmux
+
+type Server struct {
+	users UserStore
+}
+
blob - /dev/null
blob + de55899a1f20f18e75e4296862ee52c1d3e5315b (mode 644)
--- /dev/null
+++ user.go
@@ -0,0 +1,90 @@
+package mailmux
+
+import (
+	"errors"
+	"os"
+	"path"
+)
+
+type User struct {
+	name     string
+	password Password
+}
+
+type Password []byte
+
+// A UserStore provides storage and authentication of users.
+type UserStore interface {
+	// Change creates or updates the named user with the new password.
+	Change(name string, new Password) error
+	// Lookup returns the User with name.
+	// The error should be ErrUnknownUser if the user is not found.
+	Lookup(name string) (User, error)
+	// Delete removes the user from the store.
+	Delete(name string) error
+	//Authenticate(username, password string) (ticket string, err error)
+}
+
+type ticket []byte
+
+var testTicket = []byte(`TEST NOT REAL`)
+
+var (
+	ErrUserExist    = errors.New("user already exists")
+	ErrUnknownUser = errors.New("unknown user")
+)
+
+// verified checks whether ownership of username's address has
+// been confirmed.
+// func (store *astore) verified(username string) (bool, error) {
+// 	_, err := store.Lookup(username)
+// 	if errors.Is(err, ErrUnknownUser) {
+// 		return false, err
+// 	}
+//
+// 	_, err = os.Stat(path.Join(store.confirmDir, username))
+// 	if err == nil {
+// 		return false, nil
+// 	}
+// 	if errors.Is(err, fs.ErrNotExist) {
+// 		return true, nil
+// 	}
+// 	return false, fmt.Errorf("check confirm file: %w", err)
+// }
+
+// verify attempts to verify the username using the given token.
+// func (store *astore) verify(username string, tok token) error {
+// 	f, err := os.Open(path.Join(store.confirmDir, username))
+// 	if errors.Is(err, fs.ErrNotExist) {
+// 		return ErrUnknownUser
+// 	} else if err != nil {
+// 		return err
+// 	}
+// 	defer f.Close()
+// 	b, err := io.ReadAll(f)
+// 	if err != nil {
+// 		return err
+// 	}
+// 	if bytes.Equal(b, []byte(tok)) {
+// 		return os.Remove(path.Join(store.confirmDir, username))
+// 	}
+// 	return errors.New("mismatched token")
+// }
+
+func SendConfirmationMail(from, to string, tik ticket) error {
+	return nil
+	// From: $from
+	// To: $to
+	// Subject: Confirm address ownership
+	//
+	// Thanks for signing up to mailmux.net!
+	// Confirm ownership of $address by accessing the following link:
+	// https://mailmux.net/confirm?username=test@example.com&token=abcd12345
+	// Alternatively, go to https://mailmux.net/confirm
+	// enter your email address and your token:
+	//	abcd12345
+	//
+	// If you have not signed up for mailmux.net, please
+	// let us know by forwarding this mail to abuse@mailmux.net.
+	// Thank you for helping us fight spam!
+}
blob - /dev/null
blob + 8f4da24dca9b8cb822e32bb5089ba801748152ef (mode 644)
--- /dev/null
+++ userdb.go
@@ -0,0 +1,77 @@
+package mailmux
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+// UserDB is an implementation of UserStore backed by a SQLite3 database.
+// Users are fully authenticated after confirming ownership of their
+// email address by supplying a matching ticket to a generated ticket file.
+type UserDB struct {
+	*sql.DB
+	TicketDir string
+}
+
+// OpenUserDB opens the named user database file and ticket directory.
+func OpenUserDB(name, dir string) (*UserDB, error) {
+	db, err := sql.Open("sqlite3", name)
+	if err != nil {
+		return nil, err
+	}
+	return &UserDB{db, dir}, db.Ping()
+}
+
+func (db *UserDB) Lookup(name string) (User, error) {
+	var u User
+	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{}, fmt.Errorf("lookup %s: %w", name, err)
+	}
+	return u, nil
+}
+
+func (db *UserDB) add(username string, pw Password) error {
+	_, err := db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, pw)
+	if err != nil {
+		return fmt.Errorf("add %s: %w", username, err)
+	}
+	if err := createTicket(db.TicketDir, username, pw); err != nil {
+		return fmt.Errorf("add %s: create ticket: %w", username, err)
+	}
+	return nil
+}
+
+func (db *UserDB) Change(name string, new Password) error {
+	_, err := db.Lookup(name)
+	if errors.Is(err, ErrUnknownUser) {
+		return db.add(name, new)
+	}
+	if err != nil {
+		return fmt.Errorf("change %s: %w", name, err)
+	}
+
+	_, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", new, name)
+	if err != nil {
+		return fmt.Errorf("change %s: %w", name, err)
+	}
+	return nil
+}
+
+func (db *UserDB) Delete(name string) error {
+	_, err := db.Lookup(name)
+	if err != nil {
+		return fmt.Errorf("delete %s: %w", name, err)
+	}
+	_, err = db.Exec("DELETE FROM users WHERE username = ?", name)
+	if err != nil {
+		return fmt.Errorf("delete %s: %w", name, err)
+	}
+	return nil
+}