commit - 1fdf07b79b193a5a1caf7d939f36b824a13086f4
commit + 5e70998a1d4f415458485d8c6f7f85ba82dc42b3
blob - 044b38889b0da1ab6bcf5d50dda1ccb7762c6dcc
blob + 85add4b7a714a0bd3b1e17208a80dbeac77d655a
--- main.go
+++ main.go
-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
+// 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
+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
+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
+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
+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
+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
+package mailmux
+
+type Alias struct {
+ recipient string
+ destination string
+}
blob - /dev/null
blob + ebd59c328ae9e25be1ffc97a44ea3aaa8e62de1e (mode 644)
--- /dev/null
+++ random.go
+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
+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
+package mailmux
+
+type Server struct {
+ users UserStore
+}
+
blob - /dev/null
blob + de55899a1f20f18e75e4296862ee52c1d3e5315b (mode 644)
--- /dev/null
+++ user.go
+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
+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
+}