commit 5e70998a1d4f415458485d8c6f7f85ba82dc42b3 from: Oliver Lowe date: Tue Apr 12 04:18:17 2022 UTC wip 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 +}