commit ceffe7612dc3d903586064b42d91dba95e5c1001 Author: Neil Alexander Date: Wed Jul 7 18:15:07 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb6bca8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.db +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..15f9f7b --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Yggmail + +It's email, but not as you know it. + +## Introduction + +Yggmail is a single-binary all-in-one mail transfer agent which sends and receives email natively over the Yggdrasil Network. + +* Yggmail runs just about anywhere you like — your inbox is stored right on your own machine; +* Implements IMAP and SMTP protocols for sending and receiving mail, so you can use your favourite client (hopefully); +* Mails are exchanged between Yggmail users using built-in Yggdrasil connectivity; +* All mail exchange traffic between any two Yggmail nodes is always end-to-end encrypted without exception; +* Yggdrasil and Yggmail nodes on the same network are discovered automatically using multicast or you can configure a static Yggdrasil peer. + +Email addresses are based on your public key, like `neilalexander@e3bf4665ae1ff714e0112040af8ddfc8e4b664a28e4afa40746e13952550f9ef`. + +## Quickstart + +Build Yggmail by installing a recent version of Go: + +``` +go install github.com/neilalexander/yggmail/cmd/yggmail +``` + +Create a mailbox, e.g. for user `alice`. A database will automatically be created in your working directory: +``` +yggmail --createuser alice +``` + +Start Yggmail, using the database in your working directory: +``` +yggmail -smtp=localhost:1025 -imap=localhost:1026 +``` + +Connect your mail client to Yggmail. In the above example: + +* SMTP is listening on `localhost` port 1025, password authentication, no SSL/TLS +* IMAP is listening on `localhost` port 1026, password authentication, no SSL/TLS + +Then try sending a mail to another Yggmail user! + +## Parameters + +The following command line switches are supported by the `yggmail` binary: + +* `-peer tls://...` or `-peer tcp://...` — connect to a specific Yggdrasil node; +* `-database /path/to/yggmail.db` — use a specific database file; +* `-smtp listenaddr:port` — listen for SMTP on a specific address/port +* `-imap listenaddr:port` — listen for IMAP on a specific address/port; +* `-createuser username` — create a new user in the database (doesn't matter if Yggmail is running or not, just make sure that Yggmail is pointing at the right database file or that you are in the right working directory). + +## Notes + +There are a few important notes: + +* Yggmail needs to be running in order to receive inbound emails — it's therefore important to run Yggmail somewhere that will have good uptime; +* Yggmail tries to guarantee that senders are who they say they are. Your `From` address must be your Yggmail address (or at the very least, from your Yggmail domain); +* You can only email other Yggmail users, not regular email addresses on the public Internet; +* You may need to configure your client to allow "insecure" or "plaintext" authentication to IMAP/SMTP — this is because we don't support SSL/TLS on the IMAP/SMTP listeners yet. + +## Bugs + +There are probably all sorts of bugs, but the ones that we know of are: + +* IMAP behaviour might not be entirely spec-compliant in all cases, so your mileage with mail clients might vary; +* SMTP queues up outbound mails in memory rather than in the database right now — if you restart Yggmail, any unsent mails will be lost. \ No newline at end of file diff --git a/cmd/yggmail/main.go b/cmd/yggmail/main.go new file mode 100644 index 0000000..2f2b856 --- /dev/null +++ b/cmd/yggmail/main.go @@ -0,0 +1,191 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "flag" + "fmt" + "log" + "os" + "strings" + "sync" + + "github.com/emersion/go-imap/server" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "golang.org/x/term" + + "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/imapserver" + "github.com/neilalexander/yggmail/internal/smtpsender" + "github.com/neilalexander/yggmail/internal/smtpserver" + "github.com/neilalexander/yggmail/internal/storage/sqlite3" + "github.com/neilalexander/yggmail/internal/transport" +) + +var database = flag.String("database", "yggmail.db", "SQLite database file") +var smtpaddr = flag.String("smtp", "localhost:1025", "SMTP listen address") +var imapaddr = flag.String("imap", "localhost:1026", "IMAP listen address") +var peeraddr = flag.String("peer", "", "Yggdrasil static peer") +var createuser = flag.String("createuser", "", "Create a user") + +func main() { + flag.Parse() + + rawlog := log.New(os.Stdout, "", 0) + log := log.New(rawlog.Writer(), "[ \033[32mYggmail\033[0m ] ", 0) + + storage, err := sqlite3.NewSQLite3StorageStorage(*database) + if err != nil { + panic(err) + } + log.Printf("Using database file %q\n", *database) + + skStr, err := storage.ConfigGet("private_key") + if err != nil { + panic(err) + } + + sk := make(ed25519.PrivateKey, ed25519.PrivateKeySize) + if skStr == "" { + if _, sk, err = ed25519.GenerateKey(nil); err != nil { + panic(err) + } + if err := storage.ConfigSet("private_key", hex.EncodeToString(sk)); err != nil { + panic(err) + } + log.Printf("Generated new server identity") + } else { + skBytes, err := hex.DecodeString(skStr) + if err != nil { + panic(err) + } + copy(sk, skBytes) + } + pk := sk.Public().(ed25519.PublicKey) + log.Println("Mail domain:", hex.EncodeToString(pk)) + + switch { + case createuser != nil && *createuser != "": + fmt.Printf("New password: ") + password1, err := term.ReadPassword(0) + if err != nil { + panic(err) + } + fmt.Println() + fmt.Printf("Confirm password: ") + password2, err := term.ReadPassword(0) + if err != nil { + panic(err) + } + fmt.Println() + if !bytes.Equal(password1, password2) { + fmt.Println("The supplied passwords do not match") + os.Exit(1) + } + if err := storage.CreateUser(*createuser, strings.TrimSpace(string(password1))); err != nil { + fmt.Printf("Failed to create user %q\n", *createuser) + os.Exit(1) + } + if err := storage.MailboxCreate(*createuser, "INBOX"); err != nil { + panic(err) + } + fmt.Printf("Created user %q\n", *createuser) + fmt.Printf("Email address will be %s@%s\n", *createuser, hex.EncodeToString(pk)) + os.Exit(0) + } + + cfg := &config.Config{ + PublicKey: pk, + PrivateKey: sk, + } + wg := &sync.WaitGroup{} + wg.Add(2) + + transport, err := transport.NewYggdrasilTransport(rawlog, sk, pk, *peeraddr) + if err != nil { + panic(err) + } + + queues := smtpsender.NewQueues(cfg, log, transport) + + go func() { + defer wg.Done() + + imapBackend := &imapserver.Backend{ + Log: log, + Config: cfg, + Storage: storage, + } + + imapServer := server.New(imapBackend) + imapServer.Addr = *imapaddr + imapServer.AllowInsecureAuth = true + imapServer.EnableAuth(sasl.Login, func(conn server.Conn) sasl.Server { + return sasl.NewLoginServer(func(username, password string) error { + _, err := imapBackend.Login(nil, username, password) + return err + }) + }) + + log.Println("Listening for IMAP on:", imapServer.Addr) + if err := imapServer.ListenAndServe(); err != nil { + log.Fatal(err) + } + }() + + go func() { + defer wg.Done() + + localBackend := &smtpserver.Backend{ + Log: log, + Mode: smtpserver.BackendModeInternal, + Config: cfg, + Storage: storage, + Queues: queues, + } + + localServer := smtp.NewServer(localBackend) + localServer.Addr = *smtpaddr + localServer.Domain = hex.EncodeToString(pk) + localServer.MaxMessageBytes = 1024 * 1024 + localServer.MaxRecipients = 50 + localServer.AllowInsecureAuth = true + localServer.EnableAuth(sasl.Login, func(conn *smtp.Conn) sasl.Server { + return sasl.NewLoginServer(func(username, password string) error { + _, err := localBackend.Login(nil, username, password) + return err + }) + }) + + log.Println("Listening for SMTP on:", localServer.Addr) + if err := localServer.ListenAndServe(); err != nil { + log.Fatal(err) + } + }() + + go func() { + defer wg.Done() + + overlayBackend := &smtpserver.Backend{ + Log: log, + Mode: smtpserver.BackendModeExternal, + Config: cfg, + Storage: storage, + Queues: queues, + } + + overlayServer := smtp.NewServer(overlayBackend) + overlayServer.Domain = hex.EncodeToString(pk) + overlayServer.MaxMessageBytes = 1024 * 1024 + overlayServer.MaxRecipients = 50 + overlayServer.AuthDisabled = true + + if err := overlayServer.Serve(transport.Listener()); err != nil { + log.Fatal(err) + } + }() + + wg.Wait() +} diff --git a/cmd/yggproxy/main.go b/cmd/yggproxy/main.go new file mode 100644 index 0000000..0aff5f7 --- /dev/null +++ b/cmd/yggproxy/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "crypto/ed25519" + "encoding/hex" + "flag" + "log" + "net" + "os" + + "github.com/neilalexander/yggmail/internal/transport" +) + +var dst = flag.String("dst", "", "Destination public key to proxy to") +var peeraddr = flag.String("peer", "", "Yggdrasil static peer") + +func main() { + flag.Parse() + + /* + pk, sk, err := ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + */ + + sk := make(ed25519.PrivateKey, ed25519.PrivateKeySize) + if sks, err := hex.DecodeString("f50a6a4688ca602307dcf282304583b1746093a558e934d3e9a817bb1e7be77b7c824efcd702e80a3a6912e15ebc4e13454022947ce8ee46ddb871e8b9a9147f"); err != nil { + panic(err) + } else { + copy(sk, sks) + } + pk := sk.Public().(ed25519.PublicKey) + + log.Println("Private key:", hex.EncodeToString(sk)) + + log := log.New(os.Stdout, "", 0) + transport, err := transport.NewYggdrasilTransport(log, sk, pk, *peeraddr) + if err != nil { + panic(err) + } + + listener, err := net.Listen("tcp", "localhost:1026") + if err != nil { + panic(err) + } + + log.Println("Proxying", listener.Addr(), "to", *dst) + + for { + conn, err := listener.Accept() + if err != nil { + panic(err) + } + + log.Println("Accepted connection from", conn.RemoteAddr()) + + upstream, err := transport.Dial(*dst) + if err != nil { + log.Println("Failed to dial upstream:", err) + conn.Close() + continue + } + + go func(conn, upstream net.Conn) { + defer conn.Close() + defer upstream.Close() + var b [1024]byte + for { + n, err := conn.Read(b[:]) + if err != nil { + log.Println("conn.Read:", err) + return + } + _, err = upstream.Write(b[:n]) + if err != nil { + log.Println("upstream.Write:", err) + return + } + } + }(conn, upstream) + + go func(conn, upstream net.Conn) { + defer conn.Close() + defer upstream.Close() + var b [1024]byte + for { + n, err := upstream.Read(b[:]) + if err != nil { + log.Println("upstream.Read:", err) + return + } + _, err = conn.Write(b[:n]) + if err != nil { + log.Println("conn.Write:", err) + return + } + } + }(conn, upstream) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..adf5558 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/neilalexander/yggmail + +go 1.16 + +require ( + github.com/Arceliar/ironwood v0.0.0-20210619124114-6ad55cae5031 + github.com/emersion/go-imap v1.1.0 + github.com/emersion/go-imap-idle v0.0.0-20201224103203-6f42b9020098 + github.com/emersion/go-message v0.15.0 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.15.0 + github.com/gologme/log v1.2.0 + github.com/mattn/go-sqlite3 v1.14.7 + github.com/neilalexander/utp v0.1.1-0.20210705212447-691f29ad692b + github.com/yggdrasil-network/yggdrasil-go v0.4.1-0.20210707004512-3704ebf4cbea + go.uber.org/atomic v1.7.0 + golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7187357 --- /dev/null +++ b/go.sum @@ -0,0 +1,152 @@ +github.com/Arceliar/ironwood v0.0.0-20210619124114-6ad55cae5031 h1:DZVDfYhVdu+0wAiRHoY1olyNkKxIot9UjBnbQFzuUlM= +github.com/Arceliar/ironwood v0.0.0-20210619124114-6ad55cae5031/go.mod h1:RP72rucOFm5udrnEzTmIWLRVGQiV/fSUAQXJ0RST/nk= +github.com/Arceliar/phony v0.0.0-20210209235338-dde1a8dca979 h1:WndgpSW13S32VLQ3ugUxx2EnnWmgba1kCqPkd4Gk1yQ= +github.com/Arceliar/phony v0.0.0-20210209235338-dde1a8dca979/go.mod h1:6Lkn+/zJilRMsKmbmG1RPoamiArC6HS73xbwRyp3UyI= +github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= +github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= +github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= +github.com/anacrolix/envpprof v1.1.1 h1:sHQCyj7HtiSfaZAzL2rJrQdyS7odLqlwO6nhk/tG/j8= +github.com/anacrolix/envpprof v1.1.1/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= +github.com/anacrolix/log v0.3.0 h1:Btxh7GkT4JYWvWJ1uKOwgobf+7q/1eFQaDdCUXCtssw= +github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= +github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= +github.com/anacrolix/missinggo v1.2.1 h1:0IE3TqX5y5D0IxeMwTyIgqdDew4QrzcXaaEnJQyjHvw= +github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y= +github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw= +github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ= +github.com/anacrolix/sync v0.2.0 h1:oRe22/ZB+v7v/5Mbc4d2zE0AXEZy0trKyKLjqYOt6tY= +github.com/anacrolix/sync v0.2.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g= +github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= +github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk= +github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8= +github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= +github.com/cheggaaa/pb/v3 v3.0.8/go.mod h1:UICbiLec/XO6Hw6k+BHEtHeQFzzBH4i2/qk/ow1EJTA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/emersion/go-imap v1.0.6/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= +github.com/emersion/go-imap v1.1.0 h1:hAW8Dbi/AwiVO5Wi40FTVuCzVrTmwtEK6De9GSoOy+Y= +github.com/emersion/go-imap v1.1.0/go.mod h1:0hCeak4mA2z9hICM20jeqN6fyV0Oad0lZTyeeAyUS6o= +github.com/emersion/go-imap-idle v0.0.0-20201224103203-6f42b9020098 h1:J+qvrz94n18fVThwhUWwrBwRbcNqi+VgcUJlaph430A= +github.com/emersion/go-imap-idle v0.0.0-20201224103203-6f42b9020098/go.mod h1:N/6S3dRTVt8xT867m+476C16+v/Fq4WZYvh2Chg0nmg= +github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-message v0.14.1/go.mod h1:N1JWdZQ2WRUalmdHAX308CWBq747VJ8oUorFI3VCBwU= +github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= +github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= +github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hjson/hjson-go v3.1.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio= +github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= +github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kardianos/minwinsvc v1.0.0/go.mod h1:Bgd0oc+D0Qo3bBytmNtyRKVlp85dAloLKhfxanPFFRc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA= +github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= +github.com/neilalexander/utp v0.1.1-0.20210705212447-691f29ad692b h1:XNm+Ks3bVziRJxcMaIbzumWEw7l52z9Rek6cMHgln1g= +github.com/neilalexander/utp v0.1.1-0.20210705212447-691f29ad692b/go.mod h1:ylsx0342RjGHjOoVKhR/wz/7Lhiusonihfj4QLxEMcU= +github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/yggdrasil-network/yggdrasil-go v0.4.1-0.20210707004512-3704ebf4cbea h1:3J6MCIkRIlkBHqSDU+ryRGDMamLgs9S7eyB4DNSdgX0= +github.com/yggdrasil-network/yggdrasil-go v0.4.1-0.20210707004512-3704ebf4cbea/go.mod h1:/iMJjOrXRsjlFgqhWOPhecOKi7xHmHiY4/En3A42Fog= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b h1:k+E048sYJHyVnsr1GDrRZWQ32D2C7lWs9JRc0bel53A= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309040221-94ec62e08169/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210611083646-a4fc73990273 h1:faDu4veV+8pcThn4fewv6TVlNCezafGoC1gM/mxQLbQ= +golang.org/x/sys v0.0.0-20210611083646-a4fc73990273/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5-0.20201125200606-c27b9fd57aec/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7-0.20210503195748-5c7c50ebbd4f h1:yQJrRE0hDxDFmZLlRaw+3vusO4fwNHgHIjUOMO7bHYI= +golang.org/x/text v0.3.7-0.20210503195748-5c7c50ebbd4f/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.zx2c4.com/wireguard v0.0.0-20210510202332-9844c74f67ec/go.mod h1:a057zjmoc00UN7gVkaJt2sXVK523kMJcogDTEvPIasg= +golang.zx2c4.com/wireguard v0.0.0-20210604143328-f9b48a961cd2/go.mod h1:laHzsbfMhGSobUmruXWAyMKKHSqvIcrqZJMyHD+/3O8= +golang.zx2c4.com/wireguard/windows v0.3.14/go.mod h1:3P4IEAsb+BjlKZmpUXgy74c0iX9AVwwr3WcVJ8nPgME= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4b7ff7f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,10 @@ +package config + +import ( + "crypto/ed25519" +) + +type Config struct { + PublicKey ed25519.PublicKey + PrivateKey ed25519.PrivateKey +} diff --git a/internal/imapserver/backend.go b/internal/imapserver/backend.go new file mode 100644 index 0000000..eadf2b6 --- /dev/null +++ b/internal/imapserver/backend.go @@ -0,0 +1,33 @@ +package imapserver + +import ( + "fmt" + "log" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/storage" +) + +type Backend struct { + Config *config.Config + Log *log.Logger + Storage storage.Storage +} + +func (b *Backend) Login(_ *imap.ConnInfo, username, password string) (backend.User, error) { + if authed, err := b.Storage.TryAuthenticate(username, password); err != nil { + b.Log.Printf("Failed to authenticate IMAP user %q due to error: %s", username, err) + return nil, fmt.Errorf("failed to authenticate: %w", err) + } else if !authed { + b.Log.Printf("Failed to authenticate IMAP user %q\n", username) + return nil, backend.ErrInvalidCredentials + } + defer b.Log.Printf("Authenticated IMAP user %q\n", username) + user := &User{ + backend: b, + username: username, + } + return user, nil +} diff --git a/internal/imapserver/imap.go b/internal/imapserver/imap.go new file mode 100644 index 0000000..254a385 --- /dev/null +++ b/internal/imapserver/imap.go @@ -0,0 +1,20 @@ +package imapserver + +import ( + idle "github.com/emersion/go-imap-idle" + "github.com/emersion/go-imap/server" +) + +type IMAPServer struct { + server *server.Server + backend *Backend +} + +func NewIMAPServer(backend *Backend) (*IMAPServer, error) { + s := &IMAPServer{ + server: server.New(backend), + backend: backend, + } + s.server.Enable(idle.NewExtension()) + return s, nil +} diff --git a/internal/imapserver/mailbox.go b/internal/imapserver/mailbox.go new file mode 100644 index 0000000..842798b --- /dev/null +++ b/internal/imapserver/mailbox.go @@ -0,0 +1,293 @@ +package imapserver + +import ( + "bufio" + "bytes" + "fmt" + "io" + "io/ioutil" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend/backendutil" + "github.com/emersion/go-message/textproto" +) + +type Mailbox struct { + backend *Backend + name string + user *User +} + +func (mbox *Mailbox) getIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]int32, error) { + var ids []int32 + for _, set := range seqSet.Set { + for i := set.Start; i <= set.Stop; i++ { + if !uid { + pid, err := mbox.backend.Storage.MailIDForSeq(mbox.user.username, mbox.name, int(i)) + if err != nil { + return nil, fmt.Errorf("mbox.backend.Storage.MailIDForSeq: %w", err) + } + ids = append(ids, int32(pid)) + } else { + ids = append(ids, int32(i)) + } + } + } + return ids, nil +} + +func (mbox *Mailbox) Name() string { + return mbox.name +} + +func (mbox *Mailbox) Info() (*imap.MailboxInfo, error) { + info := &imap.MailboxInfo{ + Attributes: []string{}, + Delimiter: "/", + Name: mbox.name, + } + return info, nil +} + +func (mbox *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { + status := imap.NewMailboxStatus(mbox.name, items) + status.PermanentFlags = []string{ + "\\Seen", "\\Answered", "\\Flagged", "\\Deleted", + } + status.Flags = status.PermanentFlags + + for _, name := range items { + switch name { + case imap.StatusMessages: + count, err := mbox.backend.Storage.MailCount(mbox.user.username, mbox.name) + if err != nil { + return nil, fmt.Errorf("mbox.backend.Storage.MailCount: %w", err) + } + status.Messages = uint32(count) + + case imap.StatusUidNext: + id, err := mbox.backend.Storage.MailNextID(mbox.user.username, mbox.name) + if err != nil { + return nil, fmt.Errorf("mbox.backend.Storage.MailNextID: %w", err) + } + status.UidNext = uint32(id) + + case imap.StatusUidValidity: + status.UidValidity = 1 + + case imap.StatusRecent: + status.Recent = 0 // TODO + + case imap.StatusUnseen: + unseen, err := mbox.backend.Storage.MailUnseen(mbox.user.username, mbox.name) + if err != nil { + return nil, fmt.Errorf("mbox.backend.Storage.MailUnseen: %w", err) + } + status.Unseen = uint32(unseen) + } + } + + return status, nil +} + +func (mbox *Mailbox) SetSubscribed(subscribed bool) error { + return mbox.backend.Storage.MailboxSubscribe(mbox.user.username, mbox.name, subscribed) +} + +func (mbox *Mailbox) Check() error { + return nil +} + +func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { + defer close(ch) + + ids, err := mbox.getIDsFromSeqSet(uid, seqSet) + if err != nil { + fmt.Println("Failed to get IDs from sequences:", err) + return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) + } + + for _, id := range ids { + mseq, mid, body, seen, answered, flagged, deleted, datetime, err := mbox.backend.Storage.MailSelect(mbox.user.username, mbox.name, int(id)) + if err != nil { + continue + } + + fetched := imap.NewMessage(uint32(id)+1, items) + fetched.SeqNum = uint32(mseq) + fetched.Uid = uint32(mid) + + get := func() (io.Reader, textproto.Header, error) { + bodyreader := bufio.NewReader(bytes.NewReader(body)) + hdr, err := textproto.ReadHeader(bodyreader) + if err != nil { + return nil, textproto.Header{}, fmt.Errorf("textproto.ReadHeader: %w", err) + } + return bodyreader, hdr, err + } + + for _, item := range items { + switch item { + case imap.FetchEnvelope: + _, hdr, err := get() + if err != nil { + continue + } + if fetched.Envelope, err = backendutil.FetchEnvelope(hdr); err != nil { + continue + } + + case imap.FetchBody, imap.FetchBodyStructure: + bodyreader, hdr, err := get() + if err != nil { + continue + } + if fetched.BodyStructure, err = backendutil.FetchBodyStructure(hdr, bodyreader, item == imap.FetchBodyStructure); err != nil { + continue + } + + case imap.FetchFlags: + fetched.Flags = []string{} + if seen { + fetched.Flags = append(fetched.Flags, "\\Seen") + } + if answered { + fetched.Flags = append(fetched.Flags, "\\Answered") + } + if flagged { + fetched.Flags = append(fetched.Flags, "\\Flagged") + } + if deleted { + fetched.Flags = append(fetched.Flags, "\\Deleted") + } + + case imap.FetchInternalDate: + fetched.InternalDate = datetime + + case imap.FetchRFC822Size: + fetched.Size = uint32(len(body)) + + case imap.FetchUid: + fetched.Uid = uint32(id) + + default: + section, err := imap.ParseBodySectionName(item) + if err != nil { + continue + } + bodyreader, hdr, err := get() + if err != nil { + continue + } + l, err := backendutil.FetchBodySection(hdr, bodyreader, section) + if err != nil { + continue + } + fetched.Body[section] = l + } + } + + ch <- fetched + } + + return nil +} + +func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { + return mbox.backend.Storage.MailSearch(mbox.user.username, mbox.name) +} + +func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { + b, err := ioutil.ReadAll(body) + if err != nil { + return fmt.Errorf("b.ReadFrom: %w", err) + } + id, err := mbox.backend.Storage.MailCreate(mbox.user.username, mbox.name, b) + if err != nil { + return fmt.Errorf("mbox.backend.Storage.MailCreate: %w", err) + } + for _, flag := range flags { + var seen, answered, flagged, deleted bool + switch flag { + case "\\Seen": + seen = true + case "\\Answered": + answered = true + case "\\Flagged": + flagged = true + case "\\Deleted": + deleted = true + } + if err := mbox.backend.Storage.MailUpdateFlags( + mbox.user.username, mbox.name, id, seen, answered, flagged, deleted, + ); err != nil { + return err + } + } + return nil +} + +func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqSet *imap.SeqSet, op imap.FlagsOp, flags []string) error { + ids, err := mbox.getIDsFromSeqSet(uid, seqSet) + if err != nil { + return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) + } + + for _, id := range ids { + var seen, answered, flagged, deleted bool + var mid int + if op != imap.SetFlags { + var err error + _, mid, _, seen, answered, flagged, deleted, _, err = mbox.backend.Storage.MailSelect(mbox.user.username, mbox.name, int(id)) + if err != nil { + return fmt.Errorf("mbox.backend.Storage.MailSelect: %w", err) + } + } + for _, flag := range flags { + switch flag { + case "\\Seen": + seen = op != imap.RemoveFlags + case "\\Answered": + answered = op != imap.RemoveFlags + case "\\Flagged": + flagged = op != imap.RemoveFlags + case "\\Deleted": + deleted = op != imap.RemoveFlags + } + } + + if err := mbox.backend.Storage.MailUpdateFlags( + mbox.user.username, mbox.name, int(mid), seen, answered, flagged, deleted, + ); err != nil { + return err + } + } + return nil +} + +func (mbox *Mailbox) CopyMessages(uid bool, seqSet *imap.SeqSet, destName string) error { + ids, err := mbox.getIDsFromSeqSet(uid, seqSet) + if err != nil { + return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) + } + + for _, id := range ids { + _, _, body, seen, answered, flagged, deleted, _, err := mbox.backend.Storage.MailSelect(mbox.user.username, mbox.name, int(id)) + if err != nil { + return fmt.Errorf("mbox.backend.Storage.MailSelect: %w", err) + } + pid, err := mbox.backend.Storage.MailCreate(mbox.user.username, destName, body) + if err != nil { + return fmt.Errorf("mbox.backend.Storage.MailCreate: %w", err) + } + if err = mbox.backend.Storage.MailUpdateFlags(mbox.user.username, mbox.name, pid, seen, answered, flagged, deleted); err != nil { + return fmt.Errorf("mbox.backend.Storage.MailUpdateFlags: %w", err) + } + } + return nil +} + +func (mbox *Mailbox) Expunge() error { + return mbox.backend.Storage.MailExpunge(mbox.user.username, mbox.name) +} diff --git a/internal/imapserver/user.go b/internal/imapserver/user.go new file mode 100644 index 0000000..2222b3f --- /dev/null +++ b/internal/imapserver/user.go @@ -0,0 +1,75 @@ +package imapserver + +import ( + "errors" + "fmt" + + "github.com/emersion/go-imap/backend" +) + +type User struct { + backend *Backend + username string +} + +func (u *User) Username() string { + return u.username +} + +func (u *User) ListMailboxes(subscribed bool) (mailboxes []backend.Mailbox, err error) { + names, err := u.backend.Storage.MailboxList(u.username, subscribed) + if err != nil { + return nil, err + } + + for _, mailbox := range names { + mailboxes = append(mailboxes, &Mailbox{ + backend: u.backend, + user: u, + name: mailbox, + }) + } + + return +} + +func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) { + if name == "" { + return &Mailbox{ + backend: u.backend, + user: u, + name: "", + }, nil + } + ok, _ := u.backend.Storage.MailboxSelect(u.username, name) + if !ok { + return nil, fmt.Errorf("mailbox %q not found", name) + } + return &Mailbox{ + backend: u.backend, + user: u, + name: name, + }, nil +} + +func (u *User) CreateMailbox(name string) error { + return u.backend.Storage.MailboxCreate(u.username, name) +} + +func (u *User) DeleteMailbox(name string) error { + if name == "INBOX" { + return errors.New("Cannot delete INBOX") + } + return u.backend.Storage.MailboxDelete(u.username, name) +} + +func (u *User) RenameMailbox(existingName, newName string) error { + if existingName == "INBOX" { + return errors.New("Cannot rename INBOX") + } + return u.backend.Storage.MailboxRename(u.username, existingName, newName) +} + +func (u *User) Logout() error { + return nil +} diff --git a/internal/smtpsender/fifo.go b/internal/smtpsender/fifo.go new file mode 100644 index 0000000..82ab0d7 --- /dev/null +++ b/internal/smtpsender/fifo.go @@ -0,0 +1,58 @@ +package smtpsender + +import ( + "sync" +) + +type fifoQueue struct { + frames []interface{} + count int + mutex sync.Mutex + notifs chan struct{} +} + +func newFIFOQueue() *fifoQueue { + q := &fifoQueue{ + notifs: make(chan struct{}), + } + return q +} + +func (q *fifoQueue) push(frame interface{}) bool { + q.mutex.Lock() + defer q.mutex.Unlock() + q.frames = append(q.frames, frame) + q.count++ + select { + case q.notifs <- struct{}{}: + default: + } + return true +} + +func (q *fifoQueue) pop() (interface{}, bool) { + q.mutex.Lock() + defer q.mutex.Unlock() + if q.count == 0 { + return nil, false + } + frame := q.frames[0] + q.frames[0] = nil + q.frames = q.frames[1:] + q.count-- + if q.count == 0 { + q.frames = nil + } + return frame, true +} + +func (q *fifoQueue) wait() <-chan struct{} { + q.mutex.Lock() + defer q.mutex.Unlock() + if q.count > 0 { + ch := make(chan struct{}) + close(ch) + return ch + } + return q.notifs +} diff --git a/internal/smtpsender/sender.go b/internal/smtpsender/sender.go new file mode 100644 index 0000000..89b1b0a --- /dev/null +++ b/internal/smtpsender/sender.go @@ -0,0 +1,136 @@ +package smtpsender + +import ( + "encoding/hex" + "fmt" + "log" + "math" + "sync" + "time" + + "github.com/emersion/go-smtp" + "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/transport" + "go.uber.org/atomic" +) + +type Queues struct { + Config *config.Config + Log *log.Logger + Transport transport.Transport + queues sync.Map // servername -> *Queue +} + +func NewQueues(config *config.Config, log *log.Logger, transport transport.Transport) *Queues { + return &Queues{ + Config: config, + Log: log, + Transport: transport, + } +} + +func (qs *Queues) QueueFor(server string) (*Queue, error) { + v, _ := qs.queues.LoadOrStore(server, &Queue{ + queues: qs, + destination: server, + fifo: newFIFOQueue(), + }) + q, ok := v.(*Queue) + if !ok { + return nil, fmt.Errorf("type assertion error") + } + if q.running.CAS(false, true) { + go q.run() + } + return q, nil +} + +type Queue struct { + queues *Queues + destination string + running atomic.Bool + backoff atomic.Int64 + fifo *fifoQueue +} + +func (q *Queue) Queue(mail *QueuedMail) error { + q.fifo.push(mail) + if q.running.CAS(false, true) { + go q.run() + } + return nil +} + +func (q *Queue) run() { + defer q.running.Store(false) + for { + select { + case <-q.fifo.wait(): + case <-time.After(time.Second * 10): + return + } + + item, ok := q.fifo.pop() + if !ok { + continue + } + mail, ok := item.(*QueuedMail) + if !ok { + continue + } + q.queues.Log.Println("Processing mail from", mail.From, "to", mail.Destination) + + if err := func() error { + conn, err := q.queues.Transport.Dial(q.destination) + if err != nil { + return fmt.Errorf("q.queues.Transport.Dial: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, q.destination) + if err != nil { + return fmt.Errorf("smtp.NewClient: %w", err) + } + defer client.Close() + + if err := client.Hello(hex.EncodeToString(q.queues.Config.PublicKey)); err != nil { + return fmt.Errorf("client.Hello: %w", err) + } + + q.backoff.Store(0) + + if err := client.Mail(mail.From, nil); err != nil { + return fmt.Errorf("client.Mail: %w", err) + } + + if err := client.Rcpt(mail.Rcpt); err != nil { + return fmt.Errorf("client.Rcpt: %w", err) + } + + writer, err := client.Data() + if err != nil { + return fmt.Errorf("client.Data: %w", err) + } + defer writer.Close() + + if _, err := writer.Write(mail.Content); err != nil { + return fmt.Errorf("writer.Write: %w", err) + } + + return nil + }(); err != nil { + retry := time.Second * time.Duration(math.Exp2(float64(q.backoff.Inc()))) + q.queues.Log.Println("Queue error:", err, "- will retry in", retry) + time.Sleep(retry) + } else { + q.queues.Log.Println("Sent mail from", mail.From, "to", mail.Destination) + } + } +} + +type QueuedMail struct { + From string // mail address + Rcpt string // mail addresses + Destination string // server name + Content []byte +} diff --git a/internal/smtpserver/backend.go b/internal/smtpserver/backend.go new file mode 100644 index 0000000..7d57c5b --- /dev/null +++ b/internal/smtpserver/backend.go @@ -0,0 +1,79 @@ +package smtpserver + +import ( + "encoding/hex" + "fmt" + "log" + + "github.com/emersion/go-smtp" + "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/smtpsender" + "github.com/neilalexander/yggmail/internal/storage" +) + +type BackendMode int + +const ( + BackendModeInternal BackendMode = iota + BackendModeExternal +) + +type Backend struct { + Mode BackendMode + Log *log.Logger + Config *config.Config + Queues *smtpsender.Queues + Storage storage.Storage +} + +func (b *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { + switch b.Mode { + case BackendModeInternal: + // The connection came from our local listener + if authed, err := b.Storage.TryAuthenticate(username, password); err != nil { + b.Log.Printf("Failed to authenticate SMTP user %q due to error: %s", username, err) + return nil, fmt.Errorf("failed to authenticate: %w", err) + } else if !authed { + b.Log.Printf("Failed to authenticate SMTP user %q\n", username) + return nil, smtp.ErrAuthRequired + } + defer b.Log.Printf("Authenticated SMTP user %q\n", username) + return &SessionLocal{ + backend: b, + state: state, + }, nil + + case BackendModeExternal: + return nil, fmt.Errorf("Not expecting authenticated connection on external backend") + } + + return nil, fmt.Errorf("Authenticated login failed") +} + +func (b *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { + switch b.Mode { + case BackendModeInternal: + return nil, fmt.Errorf("Not expecting anonymous connection on internal backend") + + case BackendModeExternal: + // The connection came from our overlay listener, so we should check + // that they are who they claim to be + if state.Hostname != state.RemoteAddr.String() { + return nil, fmt.Errorf("You are not who you claim to be") + } + + pks, err := hex.DecodeString(state.RemoteAddr.String()) + if err != nil { + return nil, fmt.Errorf("hex.DecodeString: %w", err) + } + + b.Log.Println("Incoming SMTP session from", state.RemoteAddr.String()) + return &SessionRemote{ + backend: b, + state: state, + public: pks[:], + }, nil + } + + return nil, fmt.Errorf("Anonymous login failed") +} diff --git a/internal/smtpserver/session.go b/internal/smtpserver/session.go new file mode 100644 index 0000000..d63b3e2 --- /dev/null +++ b/internal/smtpserver/session.go @@ -0,0 +1,14 @@ +package smtpserver + +import ( + "fmt" + "strings" +) + +func parseAddress(email string) (string, string, error) { + at := strings.LastIndex(email, "@") + if at == 0 { + return "", "", fmt.Errorf("invalid email address") + } + return email[:at], email[at+1:], nil +} diff --git a/internal/smtpserver/session_local.go b/internal/smtpserver/session_local.go new file mode 100644 index 0000000..06ff5ff --- /dev/null +++ b/internal/smtpserver/session_local.go @@ -0,0 +1,110 @@ +package smtpserver + +import ( + "bytes" + "encoding/hex" + "fmt" + "io" + "time" + + "github.com/emersion/go-message" + "github.com/emersion/go-smtp" + "github.com/neilalexander/yggmail/internal/smtpsender" +) + +type SessionLocal struct { + backend *Backend + state *smtp.ConnectionState + from string + rcpt []string +} + +func (s *SessionLocal) Mail(from string, opts smtp.MailOptions) error { + _, host, err := parseAddress(from) + if err != nil { + return fmt.Errorf("parseAddress: %w", err) + } + + if host != hex.EncodeToString(s.backend.Config.PublicKey) { + return fmt.Errorf("not allowed to send outgoing mail as %s", from) + } + + s.from = from + return nil +} + +func (s *SessionLocal) Rcpt(to string) error { + s.rcpt = append(s.rcpt, to) + return nil +} + +func (s *SessionLocal) Data(r io.Reader) error { + m, err := message.Read(r) + if err != nil { + return fmt.Errorf("message.Read: %w", err) + } + + m.Header.Add( + "Received", fmt.Sprintf("from %s by Yggmail %s; %s", + s.state.RemoteAddr.String(), + hex.EncodeToString(s.backend.Config.PublicKey), + time.Now().String(), + ), + ) + + servers := make(map[string]struct{}) + + for _, rcpt := range s.rcpt { + localpart, host, err := parseAddress(rcpt) + if err != nil { + return fmt.Errorf("parseAddress: %w", err) + } + + if _, ok := servers[host]; ok { + continue + } + servers[host] = struct{}{} + + if host == hex.EncodeToString(s.backend.Config.PublicKey) { + var b bytes.Buffer + if err := m.WriteTo(&b); err != nil { + return fmt.Errorf("m.WriteTo: %w", err) + } + if _, err := s.backend.Storage.MailCreate(localpart, "INBOX", b.Bytes()); err != nil { + return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err) + } + continue + } + + queue, err := s.backend.Queues.QueueFor(host) + if err != nil { + return fmt.Errorf("s.backend.Queues.QueueFor: %w", err) + } + + mail := &smtpsender.QueuedMail{ + From: s.from, + Rcpt: rcpt, + Destination: host, + } + + var b bytes.Buffer + if err := m.WriteTo(&b); err != nil { + return fmt.Errorf("m.WriteTo: %w", err) + } + mail.Content = b.Bytes() + + if err := queue.Queue(mail); err != nil { + return fmt.Errorf("queue.Queue: %w", err) + } + + s.backend.Log.Println("Queued mail for", mail.Destination) + } + + return nil +} + +func (s *SessionLocal) Reset() {} + +func (s *SessionLocal) Logout() error { + return nil +} diff --git a/internal/smtpserver/session_remote.go b/internal/smtpserver/session_remote.go new file mode 100644 index 0000000..c988803 --- /dev/null +++ b/internal/smtpserver/session_remote.go @@ -0,0 +1,82 @@ +package smtpserver + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "fmt" + "io" + "time" + + "github.com/emersion/go-message" + "github.com/emersion/go-smtp" +) + +type SessionRemote struct { + backend *Backend + state *smtp.ConnectionState + public ed25519.PublicKey + from string + localparts []string +} + +func (s *SessionRemote) Mail(from string, opts smtp.MailOptions) error { + _, host, err := parseAddress(from) + if err != nil { + return fmt.Errorf("mail.ParseAddress: %w", err) + } + + if local := s.state.RemoteAddr.String(); local != host { + return fmt.Errorf("not allowed to send incoming mail as %s", from) + } + + s.from = from + return nil +} + +func (s *SessionRemote) Rcpt(to string) error { + user, host, err := parseAddress(to) + if err != nil { + return fmt.Errorf("mail.ParseAddress: %w", err) + } + + if local := hex.EncodeToString(s.backend.Config.PublicKey); host != local { + return fmt.Errorf("not allowed to send mail to %q", host) + } + + s.localparts = append(s.localparts, user) + return nil +} + +func (s *SessionRemote) Data(r io.Reader) error { + m, err := message.Read(r) + if err != nil { + return fmt.Errorf("message.Read: %w", err) + } + + m.Header.Add( + "Received", fmt.Sprintf("from Yggmail %s; %s", + hex.EncodeToString(s.public), + time.Now().String(), + ), + ) + + var b bytes.Buffer + if err := m.WriteTo(&b); err != nil { + return fmt.Errorf("m.WriteTo: %w", err) + } + + for _, localpart := range s.localparts { + if _, err := s.backend.Storage.MailCreate(localpart, "INBOX", b.Bytes()); err != nil { + return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err) + } + s.backend.Log.Printf("Stored new mail for local user %q from %s", localpart, s.from) + } + return nil +} + +func (s *SessionRemote) Reset() {} + +func (s *SessionRemote) Logout() error { + return nil +} diff --git a/internal/smtpserver/smtp.go b/internal/smtpserver/smtp.go new file mode 100644 index 0000000..8762536 --- /dev/null +++ b/internal/smtpserver/smtp.go @@ -0,0 +1,18 @@ +package smtpserver + +import ( + "github.com/emersion/go-smtp" +) + +type SMTPServer struct { + server *smtp.Server + backend smtp.Backend +} + +func NewSMTPServer(backend smtp.Backend) *SMTPServer { + s := &SMTPServer{ + server: smtp.NewServer(backend), + backend: backend, + } + return s +} diff --git a/internal/storage/sqlite3/sqlite3.go b/internal/storage/sqlite3/sqlite3.go new file mode 100644 index 0000000..0109d5d --- /dev/null +++ b/internal/storage/sqlite3/sqlite3.go @@ -0,0 +1,40 @@ +package sqlite3 + +import ( + "database/sql" + "fmt" + + _ "github.com/mattn/go-sqlite3" +) + +type SQLite3Storage struct { + *TableConfig + *TableUsers + *TableMailboxes + *TableMails +} + +func NewSQLite3StorageStorage(filename string) (*SQLite3Storage, error) { + db, err := sql.Open("sqlite3", "file:"+filename+"?_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("sql.Open: %w", err) + } + s := &SQLite3Storage{} + s.TableConfig, err = NewTableConfig(db) + if err != nil { + return nil, fmt.Errorf("NewTableConfig: %w", err) + } + s.TableUsers, err = NewTableUsers(db) + if err != nil { + return nil, fmt.Errorf("NewTableUsers: %w", err) + } + s.TableMailboxes, err = NewTableMailboxes(db) + if err != nil { + return nil, fmt.Errorf("NewTableMailboxes: %w", err) + } + s.TableMails, err = NewTableMails(db) + if err != nil { + return nil, fmt.Errorf("NewTableMails: %w", err) + } + return s, nil +} diff --git a/internal/storage/sqlite3/table_config.go b/internal/storage/sqlite3/table_config.go new file mode 100644 index 0000000..00ac491 --- /dev/null +++ b/internal/storage/sqlite3/table_config.go @@ -0,0 +1,61 @@ +package sqlite3 + +import ( + "database/sql" + "fmt" +) + +type TableConfig struct { + db *sql.DB + get *sql.Stmt + set *sql.Stmt +} + +const configSchema = ` + CREATE TABLE IF NOT EXISTS config ( + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(key) + ); +` + +const configGet = ` + SELECT value FROM config WHERE key = $1 +` + +const configSet = ` + INSERT OR REPLACE INTO config (key, value) VALUES($1, $2) +` + +func NewTableConfig(db *sql.DB) (*TableConfig, error) { + t := &TableConfig{ + db: db, + } + _, err := db.Exec(configSchema) + if err != nil { + return nil, fmt.Errorf("db.Exec: %w", err) + } + t.get, err = db.Prepare(configGet) + if err != nil { + return nil, fmt.Errorf("db.Prepare(get): %w", err) + } + t.set, err = db.Prepare(configSet) + if err != nil { + return nil, fmt.Errorf("db.Prepare(set): %w", err) + } + return t, nil +} + +func (t *TableConfig) ConfigGet(key string) (string, error) { + var value string + err := t.get.QueryRow(key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + return value, err +} + +func (t *TableConfig) ConfigSet(key, value string) error { + _, err := t.set.Exec(key, value) + return err +} diff --git a/internal/storage/sqlite3/table_mailboxes.go b/internal/storage/sqlite3/table_mailboxes.go new file mode 100644 index 0000000..d37e76c --- /dev/null +++ b/internal/storage/sqlite3/table_mailboxes.go @@ -0,0 +1,152 @@ +package sqlite3 + +import ( + "database/sql" + "fmt" +) + +type TableMailboxes struct { + db *sql.DB + selectMailboxes *sql.Stmt + listMailboxes *sql.Stmt + listMailboxesSubscribed *sql.Stmt + createMailbox *sql.Stmt + renameMailbox *sql.Stmt + deleteMailbox *sql.Stmt + subscribeMailbox *sql.Stmt +} + +const mailboxesSchema = ` + CREATE TABLE IF NOT EXISTS mailboxes ( + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE, + mailbox TEXT NOT NULL DEFAULT('INBOX'), + subscribed BOOLEAN NOT NULL DEFAULT 1, + PRIMARY KEY(username, mailbox) + ); +` + +const mailboxesList = ` + SELECT mailbox FROM mailboxes WHERE username = $1 +` + +const mailboxesListSubscribed = ` + SELECT mailbox FROM mailboxes WHERE username = $1 AND subscribed = 1 +` + +const mailboxesSelect = ` + SELECT mailbox FROM mailboxes WHERE username = $1 AND mailbox = $2 +` + +const mailboxesCreate = ` + INSERT INTO mailboxes (username, mailbox) VALUES($1, $2) +` + +const mailboxesRename = ` + UPDATE mailboxes SET mailbox = $1 WHERE username = $2 AND mailbox = $3 +` + +const mailboxesDelete = ` + DELETE FROM mailboxes WHERE username = $1 AND mailbox = $2 +` + +const mailboxesSubscribe = ` + UPDATE mailboxes SET subscribed = $1 WHERE username = $2 AND mailbox = $3 +` + +func NewTableMailboxes(db *sql.DB) (*TableMailboxes, error) { + t := &TableMailboxes{ + db: db, + } + _, err := db.Exec(mailboxesSchema) + if err != nil { + return nil, fmt.Errorf("db.Exec: %w", err) + } + t.listMailboxes, err = db.Prepare(mailboxesList) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err) + } + t.listMailboxesSubscribed, err = db.Prepare(mailboxesListSubscribed) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err) + } + t.selectMailboxes, err = db.Prepare(mailboxesSelect) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesSelect): %w", err) + } + t.createMailbox, err = db.Prepare(mailboxesCreate) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err) + } + t.deleteMailbox, err = db.Prepare(mailboxesDelete) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesDelete): %w", err) + } + t.renameMailbox, err = db.Prepare(mailboxesRename) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesRename): %w", err) + } + t.subscribeMailbox, err = db.Prepare(mailboxesSubscribe) + if err != nil { + return nil, fmt.Errorf("db.Prepare(mailboxesSubscribe): %w", err) + } + return t, nil +} + +func (t *TableMailboxes) MailboxList(user string, onlySubscribed bool) ([]string, error) { + stmt := t.listMailboxes + if onlySubscribed { + stmt = t.listMailboxesSubscribed + } + rows, err := stmt.Query(user) + if err != nil { + return nil, fmt.Errorf("t.listMailboxes.Query: %w", err) + } + defer rows.Close() + var mailboxes []string + for rows.Next() { + var mailbox string + if err := rows.Scan(&mailbox); err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + mailboxes = append(mailboxes, mailbox) + } + return mailboxes, nil +} + +func (t *TableMailboxes) MailboxSelect(user, mailbox string) (bool, error) { + row := t.selectMailboxes.QueryRow(user, mailbox) + if err := row.Err(); err != nil && err != sql.ErrNoRows { + return false, fmt.Errorf("row.Err: %w", err) + } else if err == sql.ErrNoRows { + return false, nil + } + var got string + if err := row.Scan(&got); err != nil { + return false, fmt.Errorf("row.Scan: %w", err) + } + return mailbox == got, nil +} + +func (t *TableMailboxes) MailboxCreate(user, name string) error { + _, err := t.createMailbox.Exec(user, name) + return err +} + +func (t *TableMailboxes) MailboxRename(user, old, new string) error { + _, err := t.renameMailbox.Exec(new, user, old) + return err +} + +func (t *TableMailboxes) MailboxDelete(user, name string) error { + _, err := t.deleteMailbox.Exec(user, name) + return err +} + +func (t *TableMailboxes) MailboxSubscribe(user, name string, subscribed bool) error { + sn := 1 + if !subscribed { + sn = 0 + } + _, err := t.subscribeMailbox.Exec(sn, user, name) + return err +} diff --git a/internal/storage/sqlite3/table_mails.go b/internal/storage/sqlite3/table_mails.go new file mode 100644 index 0000000..859a67c --- /dev/null +++ b/internal/storage/sqlite3/table_mails.go @@ -0,0 +1,227 @@ +package sqlite3 + +import ( + "database/sql" + "fmt" + "time" +) + +type TableMails struct { + db *sql.DB + selectMails *sql.Stmt + selectMail *sql.Stmt + selectMailNextID *sql.Stmt + selectIDForSeq *sql.Stmt + searchMail *sql.Stmt + createMail *sql.Stmt + countMails *sql.Stmt + countUnseenMails *sql.Stmt + updateMailFlags *sql.Stmt + deleteMail *sql.Stmt + expungeMail *sql.Stmt +} + +const mailsSchema = ` + CREATE TABLE IF NOT EXISTS mails ( + username TEXT NOT NULL, + mailbox TEXT NOT NULL, + id INTEGER NOT NULL DEFAULT 1, + mail BLOB NOT NULL, + datetime INTEGER NOT NULL, + seen BOOLEAN NOT NULL DEFAULT 0, -- the mail has been read + answered BOOLEAN NOT NULL DEFAULT 0, -- the mail has been replied to + flagged BOOLEAN NOT NULL DEFAULT 0, -- the mail has been flagged for later attention + deleted BOOLEAN NOT NULL DEFAULT 0, -- the email is marked for deletion at next EXPUNGE + PRIMARY KEY(username, mailbox, id), + FOREIGN KEY (username, mailbox) REFERENCES mailboxes(username, mailbox) ON DELETE CASCADE ON UPDATE CASCADE + ); + + DROP VIEW IF EXISTS inboxes; + CREATE VIEW IF NOT EXISTS inboxes AS SELECT * FROM ( + SELECT ROW_NUMBER() OVER (PARTITION BY username, mailbox) AS seq, * FROM mails + ) + ORDER BY username, mailbox, id; +` + +const selectMailsStmt = ` + SELECT * FROM inboxes + ORDER BY username, mailbox, id +` + +const selectMailStmt = ` + SELECT seq, id, mail, datetime, seen, answered, flagged, deleted FROM inboxes + WHERE username = $1 AND mailbox = $2 AND id = $3 + ORDER BY username, mailbox, id +` + +const selectMailCountStmt = ` + SELECT COUNT(*) FROM mails WHERE username = $1 AND mailbox = $2 +` + +const selectMailUnseenStmt = ` + SELECT COUNT(*) FROM mails WHERE username = $1 AND mailbox = $2 AND seen = 0 +` + +const searchMailStmt = ` + SELECT id FROM mails + WHERE username = $1 AND mailbox = $2 + ORDER BY username, mailbox, id +` + +const insertMailStmt = ` + INSERT INTO mails (username, mailbox, id, mail, datetime) VALUES( + $1, $2, ( + SELECT IFNULL(MAX(id)+1,1) AS id FROM mails + WHERE username = $1 AND mailbox = $2 + ), $3, $4 + ) + RETURNING id; +` + +const selectIDForSeqStmt = ` + SELECT id FROM inboxes + WHERE username = $1 AND mailbox = $2 AND seq = $3 +` + +const selectMailNextID = ` + SELECT IFNULL(MAX(id)+1,1) AS id FROM mails + WHERE username = $1 AND mailbox = $2 +` + +const updateMailFlagsStmt = ` + UPDATE mails SET seen = $1, answered = $2, flagged = $3, deleted = $4 WHERE username = $5 AND mailbox = $6 AND id = $7 +` + +const deleteMailStmt = ` + UPDATE mails SET deleted = 1 WHERE username = $1 AND mailbox = $2 AND id = $3 +` + +const expungeMailStmt = ` + DELETE FROM mails WHERE username = $1 AND mailbox = $2 AND deleted = 1 +` + +func NewTableMails(db *sql.DB) (*TableMails, error) { + t := &TableMails{ + db: db, + } + _, err := db.Exec(mailsSchema) + if err != nil { + return nil, fmt.Errorf("db.Exec: %w", err) + } + t.selectMails, err = db.Prepare(selectMailsStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectMailsStmt): %w", err) + } + t.selectMail, err = db.Prepare(selectMailStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectMailStmt): %w", err) + } + t.selectMailNextID, err = db.Prepare(selectMailNextID) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectMailNextID): %w", err) + } + t.selectIDForSeq, err = db.Prepare(selectIDForSeqStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err) + } + t.searchMail, err = db.Prepare(searchMailStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err) + } + t.createMail, err = db.Prepare(insertMailStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(insertMailStmt): %w", err) + } + t.updateMailFlags, err = db.Prepare(updateMailFlagsStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(updateMailSeenStmt): %w", err) + } + t.deleteMail, err = db.Prepare(deleteMailStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(deleteMailStmt): %w", err) + } + t.expungeMail, err = db.Prepare(expungeMailStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(expungeMailStmt): %w", err) + } + t.countMails, err = db.Prepare(selectMailCountStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectMailCountStmt): %w", err) + } + t.countUnseenMails, err = db.Prepare(selectMailUnseenStmt) + if err != nil { + return nil, fmt.Errorf("db.Prepare(selectMailUnseenStmt): %w", err) + } + return t, nil +} + +func (t *TableMails) MailCreate(user, mailbox string, data []byte) (int, error) { + var id int + err := t.createMail.QueryRow(user, mailbox, data, time.Now().Unix()).Scan(&id) + return id, err +} + +func (t *TableMails) MailSelect(user, mailbox string, id int) (int, int, []byte, bool, bool, bool, bool, time.Time, error) { + var data []byte + var seen, answered, flagged, deleted bool + var ts int64 + var seq, pid int + err := t.selectMail.QueryRow(user, mailbox, id).Scan(&seq, &pid, &data, &ts, &seen, &answered, &flagged, &deleted) + return seq, pid, data, seen, answered, flagged, deleted, time.Unix(ts, 0), err +} + +func (t *TableMails) MailSearch(user, mailbox string) ([]uint32, error) { + var ids []uint32 + rows, err := t.searchMail.Query(user, mailbox) + if err != nil { + return nil, fmt.Errorf("t.searchMail.Query: %w", err) + } + defer rows.Close() + for rows.Next() { + var id uint32 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + ids = append(ids, id) + } + return ids, nil +} + +func (t *TableMails) MailNextID(user, mailbox string) (int, error) { + var id int + err := t.selectMailNextID.QueryRow(user, mailbox).Scan(&id) + return id, err +} + +func (t *TableMails) MailIDForSeq(user, mailbox string, seq int) (int, error) { + var id int + err := t.selectIDForSeq.QueryRow(user, mailbox, seq).Scan(&id) + return id, err +} + +func (t *TableMails) MailUnseen(user, mailbox string) (int, error) { + var unseen int + err := t.countUnseenMails.QueryRow(user, mailbox).Scan(&unseen) + return unseen, err +} + +func (t *TableMails) MailUpdateFlags(user, mailbox string, id int, seen, answered, flagged, deleted bool) error { + _, err := t.updateMailFlags.Exec(seen, answered, flagged, deleted, user, mailbox, id) + return err +} + +func (t *TableMails) MailDelete(user, mailbox, id string) error { + _, err := t.deleteMail.Exec(user, mailbox, id) + return err +} + +func (t *TableMails) MailExpunge(user, mailbox string) error { + _, err := t.expungeMail.Exec(user, mailbox) + return err +} + +func (t *TableMails) MailCount(user, mailbox string) (int, error) { + var count int + err := t.countMails.QueryRow(user, mailbox).Scan(&count) + return count, err +} diff --git a/internal/storage/sqlite3/table_users.go b/internal/storage/sqlite3/table_users.go new file mode 100644 index 0000000..2b90924 --- /dev/null +++ b/internal/storage/sqlite3/table_users.go @@ -0,0 +1,76 @@ +package sqlite3 + +import ( + "database/sql" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +type TableUsers struct { + db *sql.DB + getPassword *sql.Stmt + insertUser *sql.Stmt +} + +const usersSchema = ` + CREATE TABLE IF NOT EXISTS users ( + username TEXT NOT NULL, + password TEXT NOT NULL, + PRIMARY KEY(username) + ); +` + +const usersGetPassword = ` + SELECT password FROM users WHERE username = $1 +` + +const usersInsertUser = ` + INSERT INTO users (username, password) VALUES($1, $2) +` + +func NewTableUsers(db *sql.DB) (*TableUsers, error) { + t := &TableUsers{ + db: db, + } + _, err := db.Exec(usersSchema) + if err != nil { + return nil, fmt.Errorf("db.Exec: %w", err) + } + t.getPassword, err = db.Prepare(usersGetPassword) + if err != nil { + return nil, fmt.Errorf("db.Prepare(getPassword): %w", err) + } + t.insertUser, err = db.Prepare(usersInsertUser) + if err != nil { + return nil, fmt.Errorf("db.Prepare(usersInsertUser): %w", err) + } + return t, nil +} + +func (t *TableUsers) CreateUser(username, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("bcrypt.GenerateFromPassword: %w", err) + } + if _, err := t.insertUser.Exec(username, hash); err != nil { + return fmt.Errorf("t.insertUser.Exec: %w", err) + } + return nil +} + +func (t *TableUsers) TryAuthenticate(username, password string) (bool, error) { + var dbPasswordHash []byte + err := t.getPassword.QueryRow(username).Scan(&dbPasswordHash) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + err = bcrypt.CompareHashAndPassword(dbPasswordHash, []byte(password)) + if err == nil { + return true, nil + } + return false, err +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..5a46497 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,28 @@ +package storage + +import "time" + +type Storage interface { + ConfigGet(key string) (string, error) + ConfigSet(key, value string) error + + TryAuthenticate(username, password string) (bool, error) + + MailboxSelect(user, mailbox string) (bool, error) + MailNextID(user, mailbox string) (int, error) + MailIDForSeq(user, mailbox string, id int) (int, error) + MailUnseen(user, mailbox string) (int, error) + MailboxList(user string, onlySubscribed bool) ([]string, error) + MailboxCreate(user, name string) error + MailboxRename(user, old, new string) error + MailboxDelete(user, name string) error + MailboxSubscribe(user, name string, subscribed bool) error + + MailCreate(user, mailbox string, data []byte) (int, error) + MailSelect(user, mailbox string, id int) (int, int, []byte, bool, bool, bool, bool, time.Time, error) + MailSearch(user, mailbox string) ([]uint32, error) + MailUpdateFlags(user, mailbox string, id int, seen, answered, flagged, deleted bool) error + MailDelete(user, mailbox, id string) error + MailExpunge(user, mailbox string) error + MailCount(user, mailbox string) (int, error) +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..4a48b03 --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,10 @@ +package transport + +import ( + "net" +) + +type Transport interface { + Dial(host string) (net.Conn, error) + Listener() net.Listener +} diff --git a/internal/transport/yggdrasil.go b/internal/transport/yggdrasil.go new file mode 100644 index 0000000..6d942f0 --- /dev/null +++ b/internal/transport/yggdrasil.go @@ -0,0 +1,76 @@ +package transport + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" + "log" + "net" + + iwt "github.com/Arceliar/ironwood/types" + gologme "github.com/gologme/log" + "github.com/neilalexander/utp" + "github.com/yggdrasil-network/yggdrasil-go/src/config" + "github.com/yggdrasil-network/yggdrasil-go/src/core" + "github.com/yggdrasil-network/yggdrasil-go/src/multicast" +) + +type YggdrasilTransport struct { + Sessions *utp.Socket +} + +func NewYggdrasilTransport(log *log.Logger, sk ed25519.PrivateKey, pk ed25519.PublicKey, peer string) (*YggdrasilTransport, error) { + config := &config.NodeConfig{ + PublicKey: hex.EncodeToString(pk), + PrivateKey: hex.EncodeToString(sk), + MulticastInterfaces: []config.MulticastInterfaceConfig{ + { + Regex: ".*", + Beacon: true, + Listen: true, + }, + }, + NodeInfo: map[string]interface{}{ + "name": "Yggmail", + }, + } + if peer != "" { + config.Peers = append(config.Peers, peer) + } + glog := gologme.New(log.Writer(), "[ \033[33mYggdrasil\033[0m ] ", 0) + glog.EnableLevel("warn") + glog.EnableLevel("error") + glog.EnableLevel("info") + core := &core.Core{} + if err := core.Start(config, glog); err != nil { + return nil, fmt.Errorf("core.Start: %w", err) + } + multicast := &multicast.Multicast{} + if err := multicast.Init(core, config, glog, nil); err != nil { + return nil, fmt.Errorf("multicast.Init: %w", err) + } + if err := multicast.Start(); err != nil { + return nil, fmt.Errorf("multicast.Start: %w", err) + } + us, err := utp.NewSocketFromPacketConnNoClose(core) + if err != nil { + return nil, fmt.Errorf("utp.NewSocketFromPacketConnNoClose: %w", err) + } + return &YggdrasilTransport{ + Sessions: us, + }, nil +} + +func (t *YggdrasilTransport) Dial(host string) (net.Conn, error) { + addr := make(iwt.Addr, ed25519.PublicKeySize) + k, err := hex.DecodeString(host) + if err != nil { + return nil, err + } + copy(addr, k) + return t.Sessions.DialAddr(addr) +} + +func (t *YggdrasilTransport) Listener() net.Listener { + return t.Sessions +}