diff --git a/.gitignore b/.gitignore index fb6bca8..4668aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.db -.DS_Store \ No newline at end of file +.DS_Store +test diff --git a/README.md b/README.md index 6286aed..21fe9d1 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,103 @@ -# Yggmail +# Ygggo -It's email, but not as you know it. +**Ygggo** is a fork of [Yggmail](https://www.google.com/search?q=https://github.com/yggdrasil-network/yggmail). It is a single-binary, all-in-one Mail Transfer Agent (MTA) that sends and receives email natively over the [Yggdrasil Network](https://yggdrasil-network.github.io/). -## Introduction +## 🚀 Key Improvements in Ygggo (Changelog) -Yggmail is a single-binary all-in-one mail transfer agent which sends and receives email natively over the [Yggdrasil Network](https://yggdrasil-network.github.io/). +This fork introduces several architectural shifts and performance optimizations: -* 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. +* **New Addressing Scheme:** Migrated from `hex16` to **base32**. Addresses now follow the format: `@`. +* **Inter-node Protocol:** Implemented a custom node-to-node communication protocol using **Protobuf** structures. +* **Bridge Architecture:** * Nodes can now act as bridges. You can connect to a remote node from a bridge to fetch or send mail transparently. +* Private keys are only required on the bridge; the end-node can operate with public keys only, simplifying administration. -Email addresses are based on your public key, like `89cd1ea25d99b8ccf29e454280313128c234ffb82aa0eb2e3496f6f156d063d0@yggmail`. -## Why? +* **End-to-End Encryption (E2EE):** * Integrated **age** encryption. Emails are encrypted at the source and decrypted only at the destination bridge. +* **Privacy:** Node administrators cannot see the content of the emails passing through or stored on the node. -There are all sorts of messaging services in the world but there is still a lot of value in asynchronous communication. Email is something that a lot of people understand reasonably well and there is still a huge volume of software in the world which supports email. Yggmail is designed to comply with the standards that people know and expect. -Yggdrasil is well-suited for ad-hoc mail delivery and allows Yggmail to work even in closed networks, where Internet or other connectivity is restricted or simply not available. It guarantees end-to-end encryption and handles networks with changing topologies reasonably well. +* **Mathematical Key Adaptation:** Leverages the fact that `ed25519` signing keys can be converted for `x25519` encryption, allowing your existing identity keys to handle E2EE seamlessly. +* **Performance & Refactoring:** +* **Streaming:** Switched from direct memory allocations to data streaming for mail handling. +* **Instant Sync:** Fixed mail retrieval updates; new mail is now detected and pushed almost instantly. +* **Code Quality:** Significant decomposition of the original codebase for better maintainability. + + + +--- + +## Features + +* **Sovereign Hosting:** Your inbox is stored on your own machine or node. +* **Standard Protocols:** Implements **IMAP** and **SMTP**, making it compatible with clients like Thunderbird, Outlook, or DeltaChat. +* **Automatic Discovery:** Discovers peers on the same LAN via multicast or connects to global static peers. +* **Native Privacy:** All traffic between nodes is end-to-end encrypted by Yggdrasil, with an additional layer of `age` encryption for the mail content. + +--- ## Quickstart -Use a recent version of Go to install Yggmail: +### 1. Installation -``` -go install github.com/neilalexander/yggmail/cmd/yggmail@latest -``` +Install the binary using Go: -It will then be installed into your `GOPATH`, so add that to your environment: - -``` +```bash +go install github.com/kaiyga/ygggo/cmd/yggmail@latest export PATH=$PATH:`go env GOPATH`/bin -``` - -Create a mailbox and set your password. Your Yggmail database will automatically be created in your working directory if it doesn't already exist: ``` + +### 2. Configuration + +Create your mailbox and set a password for your mail client: + +```bash yggmail -password -``` - -Start Yggmail, using the database in your working directory, with either multicast enabled, an [Yggdrasil static peer](https://publicpeers.neilalexander.dev/) specified or both: ``` -yggmail -multicast + +### 3. Execution + +Start the node with multicast enabled or connect to a [public peer](https://publicpeers.neilalexander.dev/): + +```bash +# Using a specific peer yggmail -peer=tls://... -yggmail -multicast -peer=tls://... + +# Using multicast for local discovery +yggmail -multicast + ``` -Your mail address will be printed in the log at startup. You will also use this as your username when you log into SMTP/IMAP. +*Your email address will be printed in the logs at startup.* -Connect your mail client to Yggmail. In the above example: +### 4. Client Setup -* SMTP is listening on `localhost` port 1025, username is your mail address, plain password authentication, no SSL/TLS -* IMAP is listening on `localhost` port 1143, username is your mail address, plain password authentication, no SSL/TLS +Connect your favorite mail client to the following local endpoints: -Then try sending a mail to another Yggmail user! +* **SMTP:** `localhost:1025` (No SSL/TLS, Plain Password) +* **IMAP:** `localhost:1143` (No SSL/TLS, Plain Password) +* **Username:** Your full Ygggo address. + +--- ## Parameters -The following command line switches are supported by the `yggmail` binary: +| Switch | Description | +| --- | --- | +| `-peer=...` | Connect to a specific Yggdrasil node (e.g., `tls://...`). | +| `-multicast` | Enable multicast discovery for LAN nodes. | +| `-database` | Path to the `yggmail.db` file (default is current directory). | +| `-smtp` | Listening address/port for SMTP (default `127.0.0.1:1025`). | +| `-imap` | Listening address/port for IMAP (default `127.0.0.1:1143`). | +| `-password` | Set your IMAP/SMTP password interactively. | +| `-passwordhash` | Set the IMAP/SMTP password using a pre-generated bcrypt hash. | -* `-peer=tls://...` or `-peer=tcp://...` — connect to a specific Yggdrasil node, like one of the [Public Peers](https://publicpeers.neilalexander.dev/); -* `-multicast` - enable multicast peer discovery for Yggdrasil nodes on your LAN -* `-mcastregexp=".*"` - regexp used in muticast peer discovery for interface name selection. -* `-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; -* `-password` — set your IMAP/SMTP password (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). -* `-passwordhash` — Like `-password` however this sets what must be directly in the database. This assumes you passed a bcrypt hash +--- -## Notes +## Important 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; -* 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; -* Yggmail won't transport mails larger than 1MB right now. - -## 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; -* IMAP search isn't implemented yet and will instead return all mails. - -The code's also a bit of a mess, so sorry about that. +* **Uptime:** Ygggo must be running to receive inbound emails. +* **Closed Ecosystem:** You can only email other Ygggo/Yggmail users. It does not route to the public Internet (Gmail, etc.). +* **Client Compatibility:** You may need to enable "Allow insecure authentication" in your mail client since the local listeners do not use SSL/TLS yet. +* **Size Limit:** Emails are currently capped at default **10mb** max **4GB**. diff --git a/cmd/yggmail/main.go b/cmd/yggmail/main.go index f84bcc7..643ecf8 100644 --- a/cmd/yggmail/main.go +++ b/cmd/yggmail/main.go @@ -1,5 +1,6 @@ /* - * Copyright (c) 2021 Neil Alexander + * Original Copyright (c) 2026 Kaiy Ragur + * Original Copyright (c) 2021 Neil Alexander * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -9,9 +10,7 @@ package main import ( - "bytes" "crypto/ed25519" - "encoding/hex" "flag" "fmt" "log" @@ -23,32 +22,41 @@ import ( "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/fatih/color" - "golang.org/x/term" + "net/http" + _ "net/http/pprof" + + "github.com/neilalexander/yggmail/internal/account" "github.com/neilalexander/yggmail/internal/config" "github.com/neilalexander/yggmail/internal/imapserver" + "github.com/neilalexander/yggmail/internal/notify" + "github.com/neilalexander/yggmail/internal/remote" + "github.com/neilalexander/yggmail/internal/shell" "github.com/neilalexander/yggmail/internal/smtpsender" "github.com/neilalexander/yggmail/internal/smtpserver" + "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/filestore" + "github.com/neilalexander/yggmail/internal/storage/local" "github.com/neilalexander/yggmail/internal/storage/sqlite3" "github.com/neilalexander/yggmail/internal/transport" "github.com/neilalexander/yggmail/internal/utils" "github.com/neilalexander/yggmail/internal/welcome" - - "golang.org/x/crypto/bcrypt" ) type peerAddrList []string -func (i *peerAddrList) String() string { - return strings.Join(*i, ", ") -} - +func (i *peerAddrList) String() string { return strings.Join(*i, ", ") } func (i *peerAddrList) Set(value string) error { *i = append(*i, value) return nil } func main() { + + go func() { + http.ListenAndServe("localhost:6060", nil) + }() + rawlog := log.New(color.Output, "", 0) green := color.New(color.FgGreen).SprintfFunc() log := log.New(rawlog.Writer(), fmt.Sprintf("[ %s ] ", green("Yggmail")), log.LstdFlags|log.Lmsgprefix) @@ -59,197 +67,161 @@ func main() { imapaddr := flag.String("imap", "localhost:1143", "IMAP listen address") multicast := flag.Bool("multicast", false, "Connect to Yggdrasil peers on your LAN") mcastregexp := flag.String("mcastregexp", ".*", "Regexp for multicast") - password := flag.Bool("password", false, "Set a new IMAP/SMTP password") - passwordhash := flag.String("passwordhash", "", "Set a new IMAP/SMTP password (hash)") - flag.Var(&peerAddrs, "peer", "Connect to a specific Yggdrasil static peer (this option can be given more than once)") + shellMode := flag.String("sh", "", "-sh \"command args\"") + flag.Var(&peerAddrs, "peer", "Connect to a specific Yggdrasil static peer") flag.Parse() - if flag.NFlag() == 0 { - fmt.Println("Yggmail must be started with either one or more Yggdrasil peers") - fmt.Println("specified, multicast enabled, or both.") - fmt.Println() - fmt.Println("Available options:") - fmt.Println() - flag.PrintDefaults() - os.Exit(0) - } - - storage, err := sqlite3.NewSQLite3StorageStorage(*database) + db, err := sqlite3.NewSQLite3StorageStorage(*database) if err != nil { - panic(err) + log.Fatalf("Failed to open database: %v", err) } - defer storage.Close() // nolint:errcheck - log.Printf("Using database file %q\n", *database) + defer db.Close() - skStr, err := storage.ConfigGet("private_key") + fs, err := filestore.NewFileStore("./mails/") if err != nil { - panic(err) + log.Fatalf("Failed to open filesystem: %v", 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) - mailAddrUser := hex.EncodeToString(pk) - mailAddr := fmt.Sprintf("%s@%s", mailAddrUser, utils.Domain) - log.Printf("Mail address: %s\n", mailAddr) + storage := local.NewLocalStorage(db, fs, 0) - for _, name := range []string{"INBOX", "Outbox", "Sent"} { - if err := storage.MailboxCreate(name); err != nil { - panic(err) - } - } - - welcome.Onboard(mailAddrUser, storage, log) - - switch { - case password != nil && *password: - log.Println("Please enter your new password:") - password1, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - panic(err) - } - fmt.Println() - log.Println("Please enter your new password again:") - password2, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - panic(err) - } - fmt.Println() - if !bytes.Equal(password1, password2) { - log.Println("The supplied passwords do not match") - os.Exit(1) - } - - // trim away whitespace of UTF-8 bytes now as string - finalPassword := strings.TrimSpace(string(password1)) - - // perform hash - hash, err := bcrypt.GenerateFromPassword([]byte(finalPassword), bcrypt.DefaultCost) - if err != nil { - log.Printf("bcrypt.GenerateFromPassword: %v\n", err) - os.Exit(1) - } else if err := storage.ConfigSetPassword(string(hash)); err != nil { - log.Println("Failed to set password:", err) - os.Exit(1) - } - - log.Println("Password for IMAP and SMTP has been updated!") - os.Exit(0) - case passwordhash != nil && *passwordhash != "": - var hash = strings.TrimSpace(*passwordhash) - if len(hash) == 0 { - log.Println("Password hash cannot be blank") - os.Exit(1) - } - - log.Printf("Using password hash: '%v'\n", hash) - - if _, err := bcrypt.Cost(([]byte)(hash)); err != nil { - log.Printf("The provided hash is invalid %v\n", err) - os.Exit(1) - } else if err := storage.ConfigSetPassword(hash); err != nil { - log.Println("Failed to set password: ", err) - os.Exit(1) - } - - log.Println("Password for IMAP and SMTP has been updated!") - case (multicast == nil || !*multicast) && len(peerAddrs) == 0: - log.Printf("You must specify either -peer, -multicast or both!") - os.Exit(0) - - } + accManager := account.NewManager(storage, log) + nodeSk, nodePk := getID(storage, log) cfg := &config.Config{ - PublicKey: pk, - PrivateKey: sk, + PublicKey: nodePk, + PrivateKey: nodeSk, } - transport, err := transport.NewYggdrasilTransport(rawlog, sk, pk, peerAddrs, *multicast, *mcastregexp) + if *shellMode != "" { + s := shell.NewShell(storage, log, cfg) + args := strings.Split(*shellMode, " ") + s.Run(args[0], args[1:]) + return + } + + checkFirstRun(accManager, storage, log, nodePk) + + tr, err := transport.NewYggdrasilTransport(rawlog, nodeSk, nodePk, peerAddrs, *multicast, *mcastregexp) if err != nil { - panic(err) + log.Fatalf("Failed to setup Yggdrasil: %v", err) } - queues := smtpsender.NewQueues(cfg, log, transport, storage) - var notify *imapserver.IMAPNotify + // Notify bus + notify := notify.NewNotifyBus(log) - imapBackend := &imapserver.Backend{ - Log: log, - Config: cfg, - Storage: storage, - } + // Imap Frontend + imapBackend := imapserver.NewBackend( + cfg, + log, + storage, + accManager, + tr, + notify, + ) - _, notify, err = imapserver.NewIMAPServer(imapBackend, *imapaddr, true) + srv, err := imapserver.NewIMAPServer(imapBackend, *imapaddr, true) if err != nil { log.Fatal(err) } - log.Println("Listening for IMAP on:", *imapaddr) + imapBackend.Server = srv + // Queues schedule + + // For connect with Peers and Push Mail we sign request + nodeSigner := func(data []byte) ([]byte, error) { + return ed25519.Sign(nodeSk, data), nil + } + nodeRPCClient := remote.NewClient( + nodePk, + nodeSigner, + tr, + ) + queues := smtpsender.NewQueues(cfg, log, tr, storage, nodeRPCClient, notify) + + // SMTP-Frontend go func() { localBackend := &smtpserver.Backend{ - Log: log, - Mode: smtpserver.BackendModeInternal, - Config: cfg, - Storage: storage, - Queues: queues, - Notify: notify, + Log: log, + Mode: smtpserver.BackendModeInternal, + // Config: cfg, + Storage: storage, + Queues: queues, + Notify: notify, + Account: accManager, + NodePublicKey: cfg.PublicKey, } - localServer := smtp.NewServer(localBackend) - localServer.Addr = *smtpaddr - localServer.Domain = hex.EncodeToString(pk) - localServer.MaxMessageBytes = 1024 * 1024 * 32 - localServer.MaxRecipients = 50 - localServer.AllowInsecureAuth = true - localServer.EnableAuth(sasl.Login, func(conn *smtp.Conn) sasl.Server { + server := smtp.NewServer(localBackend) + server.Addr = *smtpaddr + server.Domain = utils.EncodeString(nodePk) + server.AllowInsecureAuth = true + server.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.Println("Listening for local SMTP on:", server.Addr) + if err := server.ListenAndServe(); err != nil { log.Fatal(err) } }() + // RPC Mail-Server + remoteSrv := remote.NewRemoteServer(cfg.PublicKey, storage, accManager, log, notify, queues) go func() { - overlayBackend := &smtpserver.Backend{ - Log: log, - Mode: smtpserver.BackendModeExternal, - Config: cfg, - Storage: storage, - Queues: queues, - Notify: notify, - } - - overlayServer := smtp.NewServer(overlayBackend) - overlayServer.Domain = hex.EncodeToString(pk) - overlayServer.MaxMessageBytes = 1024 * 1024 * 32 - overlayServer.MaxRecipients = 50 - overlayServer.AuthDisabled = true - - if err := overlayServer.Serve(transport.Listener()); err != nil { - log.Fatal(err) + if err := remoteSrv.Serve(tr.Listener()); err != nil { + log.Fatalf("Remote Storage Server failed: %v", err) } }() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs - log.Println("Shutting down") + log.Println("Shutting down...") +} + +func getID(s storage.Storage, l *log.Logger) (ed25519.PrivateKey, ed25519.PublicKey) { + skStr, _ := s.ConfigGet("private_key") + if skStr == "" { + _, sk, _ := ed25519.GenerateKey(nil) + s.ConfigSet("private_key", utils.EncodeString(sk)) + l.Println("Generated new Yggdrasil Node Identity") + return sk, sk.Public().(ed25519.PublicKey) + } + skBytes, _ := utils.DecodeString(skStr) + sk := ed25519.PrivateKey(skBytes) + return sk, sk.Public().(ed25519.PublicKey) +} + +func checkFirstRun(m *account.Manager, s storage.Storage, l *log.Logger, nodePk ed25519.PublicKey) { + accounts, _ := m.AccountList() + if len(accounts) > 0 { + return + } + + l.Println("--- FIRST RUN: CREATE YOUR ACCOUNT ---") + + pProvider := func() (string, error) { + p, err := account.DefaultProvider("Enter password for your new mail account: ") + if err != nil { + return "", err + } + p2, err := account.DefaultProvider("Comfirm your password: ") + if err != nil { + return "", err + } + if p == p2 { + return p2, nil + } + return "", fmt.Errorf("Password not match") + } + + acc, err := m.CreateNewAccount(pProvider) + if err != nil { + l.Fatalf("Failed to create initial account: %v", err) + } + welcome.Onboard(acc.PublicKey, s, l) + l.Printf("SUCCESS! Your address is: %s@%s\n", acc.PublicKey, utils.EncodeString(nodePk)) + l.Println("--------------------------------------") } diff --git a/go.mod b/go.mod index bf8e12d..ff46cf5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/neilalexander/yggmail go 1.24.0 require ( + filippo.io/edwards25519 v1.1.0 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap-idle v0.0.0-20210907174914-db2568431445 github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872 @@ -17,9 +18,12 @@ require ( go.uber.org/atomic v1.11.0 golang.org/x/crypto v0.45.0 golang.org/x/term v0.37.0 + google.golang.org/protobuf v1.36.11 ) require ( + filippo.io/age v1.3.1 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/Arceliar/ironwood v0.0.0-20251124020000-e1358f790504 // indirect github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect diff --git a/go.sum b/go.sum index 17dd8c9..bebf076 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Arceliar/ironwood v0.0.0-20251124020000-e1358f790504 h1:yQNDl2lGRV61efEug+cTnLYs7qnhnCm1TM42wZ+9xtE= github.com/Arceliar/ironwood v0.0.0-20251124020000-e1358f790504/go.mod h1:SrrElc3FFMpYCODSr11jWbLFeOM8WsY+DbDY/l2AXF0= github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d h1:UK9fsWbWqwIQkMCz1CP+v5pGbsGoWAw6g4AyvMpm1EM= @@ -36,6 +42,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gologme/log v1.3.0 h1:l781G4dE+pbigClDSDzSaaYKtiueHCILUa/qSDsmHAo= github.com/gologme/log v1.3.0/go.mod h1:yKT+DvIPdDdDoPtqFrFxheooyVmoqi0BAsw+erN3wA4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/hjson/hjson-go/v4 v4.5.0 h1:ZHLiZ+HaGqPOtEe8T6qY8QHnoEsAeBv8wqxniQAp+CY= github.com/hjson/hjson-go/v4 v4.5.0/go.mod h1:4zx6c7Y0vWcm8IRyVoQJUHAPJLXLvbG6X8nk1RLigSo= github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= @@ -115,5 +123,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/account/manager.go b/internal/account/manager.go new file mode 100644 index 0000000..e176c99 --- /dev/null +++ b/internal/account/manager.go @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package account + +import ( + "crypto/ed25519" + "errors" + "fmt" + "log" + "os" + "strings" + + "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/types" + "github.com/neilalexander/yggmail/internal/utils" + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +type Manager struct { + storage storage.Storage + log *log.Logger +} + +type SecretProvider func() (string, error) + +// Function for wrapping by your Provider +func DefaultProvider(msg string) (string, error) { + + fmt.Print(msg) + p, err := term.ReadPassword(int(os.Stdin.Fd())) + password := strings.TrimSpace(string(p)) + fmt.Println() + + return password, err +} + +func NewManager(storage storage.Storage, log *log.Logger) *Manager { + return &Manager{storage: storage, log: log} +} + +func (m *Manager) AccountList() ([]*types.Account, error) { return m.storage.AccountList() } + +func (m *Manager) CreateNewAccount(p SecretProvider) (*types.Account, error) { + + _, sk, err := ed25519.GenerateKey(nil) + if err != nil { + return nil, fmt.Errorf("account.Manager.CreateNewAccount: GenerateKey: %w", err) + } + + acc, err := m.CreateAccount(sk, p) + if err != nil { + return nil, fmt.Errorf("account.Manager.CreateNewAccount: %w", err) + } + return acc, nil +} + +func (m *Manager) CreateAccount(sk ed25519.PrivateKey, p SecretProvider) (*types.Account, error) { + if len(sk) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("account.Manager.CreateAccount: PrivateKeySize != 32") + } + password, err := p() + if err != nil { + return nil, fmt.Errorf("account.Manger.CreateAccount: SecretProvider: %w", err) + } + pk := sk.Public().(ed25519.PublicKey) + + enSk, err := utils.AESEncrypt(utils.EncodeString(sk), password) + if err != nil { + return nil, fmt.Errorf("account.Manger.CreateAccount: encrypt privateKey: %w", err) + } + + passwdhash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("account.Manager.CreateAccount: hash password: %w", err) + } + + // nulling sec-data + sk = nil + password = "" + + acc := &types.Account{ + PublicKey: utils.EncodeString(pk), + // TODO Added Aliases.__. + Aliases: "", + PasswdHash: string(passwdhash), + Privatekey: enSk, + } + + if err := m.storage.AccountCreate(acc); err != nil { + return nil, fmt.Errorf("account.Manager.CreateAccount: storage create account: %w", err) + } + for _, folder := range []string{"INBOX", "Outbox", "Sent", "Trash", "Drafts"} { + m.storage.MailboxCreate(acc.PublicKey, folder) + } + + return acc, nil +} + +func (m *Manager) AddAccount(pubkey string) (*types.Account, error) { + pkb, err := utils.DecodeString(pubkey) + if err != nil { + return nil, fmt.Errorf("account.Manager.AddAccount: can't decode account key") + } + if len(pkb) != ed25519.PublicKeySize { + return nil, fmt.Errorf("account.Manager.AddAccount: wrong key size") + } + + acc := &types.Account{ + PublicKey: utils.EncodeString(pkb), + // TODO Added Aliases.__. + Aliases: "", + PasswdHash: "", + Privatekey: "", + } + + if err := m.storage.AccountCreate(acc); err != nil { + return nil, fmt.Errorf("account.Manager.CreateAccount: storage create account: %w", err) + } + for _, folder := range []string{"INBOX", "Outbox", "Sent", "Trash", "Drafts"} { + m.storage.MailboxCreate(acc.PublicKey, folder) + } + return acc, nil +} + +func (m *Manager) GetAccount(pubkey string) (*types.Account, error) { + acc, err := m.storage.AccountGet(pubkey) + if err != nil { + return nil, fmt.Errorf("failed to find: %w", err) + } + if acc == nil { + return nil, fmt.Errorf("failed to find: wrong credentials : %w", err) + // return nil, fmt.Errorf("failed to find: wrong username : %w", err) + } + return acc, nil +} + +func (m *Manager) DeleteAccount(pubkey string) error { + if err := m.storage.AccountDelete(pubkey); err != nil { + return fmt.Errorf("account.Manager.DeleteAccount: %w", err) + } + return nil +} + +var ErrInvalidCredentials = errors.New("Invalid credentials") + +func (m *Manager) Login(username, password string) (session *Session, err error) { + _, aPk, err := utils.ParseAddress(username) + if err != nil { + return nil, fmt.Errorf("failed to authenticate: wrong domain or username") + } + // Trim away whitespace of UTF-8 bytes now as string + password = strings.TrimSpace(password) + + // For security all errors is same. Uncomment for debug! + acc, err := m.storage.AccountGet(utils.EncodeString(aPk)) + if err != nil || acc == nil { + // return nil, fmt.Errorf("failed to authenticate: storage: %w", err) + return nil, ErrInvalidCredentials + } + if err := bcrypt.CompareHashAndPassword([]byte(acc.PasswdHash), []byte(password)); err != nil { + // return nil, fmt.Errorf("failed to authenticate: wrong password : %w", err) + return nil, ErrInvalidCredentials + } + + // Autheticated! + + if acc.PasswdHash == "" || acc.Privatekey == "" { + return nil, fmt.Errorf("failed have not account.PrivateKey for client mode!") + } + + aSkStr, err := utils.AESDecrypt(acc.Privatekey, password) + if err != nil { + return nil, fmt.Errorf("failed to authenticate: Privkey AESDecrypt: %w", err) + } + password = "" + aSk, err := utils.DecodeString(aSkStr) + if err != nil { + return nil, fmt.Errorf("failed to authenticate: Privkey Parse: %w", err) + } + aSkStr = "" + return NewSession(acc, ed25519.PrivateKey(aSk)), nil +} + +func (m *Manager) ChangePassword(pubkey string) { + panic("Not Implemented") +} diff --git a/internal/account/session.go b/internal/account/session.go new file mode 100644 index 0000000..8d779e1 --- /dev/null +++ b/internal/account/session.go @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package account + +import ( + "crypto/ed25519" + "fmt" + "io" + + "github.com/neilalexander/yggmail/internal/storage/types" + "github.com/neilalexander/yggmail/internal/utils" + "github.com/neilalexander/yggmail/internal/utils/age" + "golang.org/x/crypto/bcrypt" +) + +// Incapsulate all logic with cryptography by account +type Session struct { + account *types.Account + privKey ed25519.PrivateKey +} + +// Constructor +func NewSession(a *types.Account, p ed25519.PrivateKey) *Session { + return &Session{ + account: a, + privKey: p, + } +} + +func (s *Session) Username() string { return s.account.PublicKey } +func (s *Session) Publickey() ed25519.PublicKey { return s.privKey.Public().(ed25519.PublicKey) } +func (s *Session) GetAccount() *types.Account { return s.account } + +func (s *Session) Sign(data []byte) (sign []byte, err error) { + sign = ed25519.Sign(s.privKey, data) + return sign, nil +} + +func (s *Session) Decrypt(r io.Reader) (io.Reader, error) { + i, err := age.Identity(s.privKey) + if err != nil { + return nil, err + } + return age.DecryptArmor(r, i) +} + +func (s *Session) Encrypt(w io.Writer, rcpt ...ed25519.PublicKey) (io.WriteCloser, error) { + r, err := age.Recipient(rcpt...) + if err != nil { + return nil, err + } + return age.EncryptArmor(w, r...) +} + +func (s *Session) ChangePassword(p SecretProvider) (enSk string, passwdHash []byte, err error) { + sk := s.privKey + if len(sk) != ed25519.PrivateKeySize { + return "", nil, fmt.Errorf("session.ChangePassword:: PrivateKeySize != 32") + } + password, err := p() + if err != nil { + return "", nil, fmt.Errorf("session.ChangePassword: SecretProvider: %w", err) + } + enSk, err = utils.AESEncrypt(utils.EncodeString(sk), password) + if err != nil { + return "", nil, fmt.Errorf("session.ChangePassword: encrypt privateKey: %w", err) + } + + passwdhash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", nil, fmt.Errorf("session.ChangePassword:: hash password: %w", err) + } + + // nulling sec-data + password = "" + + return enSk, passwdhash, nil +} diff --git a/internal/imapserver/backend.go b/internal/imapserver/backend.go index c39c386..cabbb92 100644 --- a/internal/imapserver/backend.go +++ b/internal/imapserver/backend.go @@ -9,46 +9,89 @@ package imapserver import ( - "encoding/hex" "fmt" "log" "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" + "github.com/neilalexander/yggmail/internal/account" "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/notify" + nfremote "github.com/neilalexander/yggmail/internal/notify/remote" "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/remote" + "github.com/neilalexander/yggmail/internal/transport" "github.com/neilalexander/yggmail/internal/utils" ) type Backend struct { - Config *config.Config - Log *log.Logger - Storage storage.Storage - Server *IMAPServer + Config *config.Config + Log *log.Logger + Storage storage.Storage + Server *IMAPServer + Account *account.Manager + Transport transport.Transport + Notify *notify.Notify + updates chan backend.Update +} + +func NewBackend( + config *config.Config, + log *log.Logger, + storage storage.Storage, + // server *IMAPServer, + account *account.Manager, + transport transport.Transport, + notify *notify.Notify, +) *Backend { + b := &Backend{ + Config: config, + Log: log, + Storage: storage, + //Server: server, + Account: account, + Transport: transport, + Notify: notify, + updates: make(chan backend.Update, 100), + } + b.listenUpdates() + return b +} +func (b *Backend) Updates() <-chan backend.Update { + b.Log.Println("Updates is loaded!") + return b.updates } func (b *Backend) Login(conn *imap.ConnInfo, username, password string) (backend.User, error) { // If our username is email-like, then take just the localpart - if pk, err := utils.ParseAddress(username); err == nil { - if !pk.Equal(b.Config.PublicKey) { - b.Log.Println("Failed to authenticate IMAP user due to wrong domain", hex.EncodeToString(pk), hex.EncodeToString(b.Config.PublicKey)) - return nil, fmt.Errorf("failed to authenticate: wrong domain in username") - } + dPk, _, err := utils.ParseAddress(username) + if err != nil { + return nil, fmt.Errorf("Failed ") } - username = hex.EncodeToString(b.Config.PublicKey) - if authed, err := b.Storage.ConfigTryPassword(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 + + remoteDomain := !dPk.Equal(b.Config.PublicKey) + + session, err := b.Account.Login(username, password) + if err != nil { + return nil, fmt.Errorf("IMAP: Error to authenticate: %w", err) + } + + defer b.Log.Printf("IMAP: Authenticated user from %s as %q\n", conn.RemoteAddr.String(), username) + + var storage storage.Storage + if remoteDomain { + storage = remote.NewRemoteStorage(dPk, session, b.Transport) + notifer := nfremote.NewRemoteNotifer(dPk, session.Publickey(), session.Sign, b.Transport) + notifer.StartRemoteNotifer(b.Notify, session.Publickey(), "INBOX") + } else { + storage = b.Storage } - defer b.Log.Printf("Authenticated IMAP user from %s as %q\n", conn.RemoteAddr.String(), username) user := &User{ - backend: b, - username: username, + storage: storage, + username: session.Username(), + session: session, conn: conn, - log: b.Log, + log: b.Log, } return user, nil } diff --git a/internal/imapserver/imap.go b/internal/imapserver/imap.go index 02e0628..13ba544 100644 --- a/internal/imapserver/imap.go +++ b/internal/imapserver/imap.go @@ -10,6 +10,8 @@ package imapserver import ( "log" + "net" + "time" idle "github.com/emersion/go-imap-idle" move "github.com/emersion/go-imap-move" @@ -20,31 +22,61 @@ import ( type IMAPServer struct { server *server.Server backend *Backend - notify *IMAPNotify } -func NewIMAPServer(backend *Backend, addr string, insecure bool) (*IMAPServer, *IMAPNotify, error) { +type tcpNoDelayListener struct { + net.Listener +} + +func (l *tcpNoDelayListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + if tcpConn, ok := conn.(*net.TCPConn); ok { + tcpConn.SetNoDelay(true) + tcpConn.SetKeepAlive(true) + tcpConn.SetKeepAlivePeriod(time.Second * 5) + } + return conn, nil +} + +func NewIMAPServer(backend *Backend, addr string, insecure bool) (*IMAPServer, error) { + s := &IMAPServer{ server: server.New(backend), backend: backend, } - s.notify = NewIMAPNotify(s.server, backend.Log) + s.server.Addr = addr s.server.AllowInsecureAuth = insecure - //s.server.Debug = os.Stdout + + // s.server.Debug = func() io.Writer { + // return log.New(os.Stdout, "IMAP :", log.Default().Flags()).Writer() + // }() s.server.Enable(idle.NewExtension()) s.server.Enable(move.NewExtension()) - // s.server.Enable(s.notify) s.server.EnableAuth(sasl.Login, func(conn server.Conn) sasl.Server { return sasl.NewLoginServer(func(username, password string) error { _, err := s.backend.Login(nil, username, password) return err }) }) + // go func() { + // if err := s.server.ListenAndServe(); err != nil { + // log.Fatal(err) + // } + // }() go func() { - if err := s.server.ListenAndServe(); err != nil { + l, err := net.Listen("tcp", s.server.Addr) + if err != nil { + log.Fatal(err) + } + + // Оборачиваем листенер в наш тюнингованный вариант + if err := s.server.Serve(&tcpNoDelayListener{l}); err != nil { log.Fatal(err) } }() - return s, s.notify, nil + return s, nil } diff --git a/internal/imapserver/mailbox.go b/internal/imapserver/mailbox.go index 531d81c..018622d 100644 --- a/internal/imapserver/mailbox.go +++ b/internal/imapserver/mailbox.go @@ -1,49 +1,310 @@ /* - * Copyright (c) 2021 Neil Alexander + * (Original Copyright (c) 2021 Neil Alexander) * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package imapserver import ( "bufio" - "bytes" - "fmt" "io" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend/backendutil" "github.com/emersion/go-message/textproto" + "github.com/neilalexander/yggmail/internal/storage" "github.com/neilalexander/yggmail/internal/storage/types" + "github.com/neilalexander/yggmail/internal/utils/e2ee" ) type Mailbox struct { - backend *Backend + storage storage.Storage name string user *User } +func (mbox *Mailbox) Name() string { + return mbox.name +} + +func (mbox *Mailbox) Info() (*imap.MailboxInfo, error) { + return &imap.MailboxInfo{ + Attributes: []string{}, + Delimiter: "/", + Name: mbox.name, + }, nil +} + +func (mbox *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { + pk := mbox.user.username + 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, _ := mbox.storage.MailCount(pk, mbox.name) + status.Messages = uint32(count) + case imap.StatusUidNext: + id, _ := mbox.storage.MailNextID(pk, mbox.name) + status.UidNext = uint32(id) + case imap.StatusUidValidity: + status.UidValidity = 1 + case imap.StatusUnseen: + unseen, _ := mbox.storage.MailUnseen(pk, mbox.name) + status.Unseen = uint32(unseen) + } + } + return status, nil +} + +func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { + defer close(ch) + pk := mbox.user.username + + ids, err := mbox.getIDsFromSeqSet(uid, seqSet) + if err != nil { + return err + } + + for _, id := range ids { + mseq, m, err := mbox.storage.MailSelect(pk, mbox.name, int(id)) + if err != nil { + continue + } + + // n, _ := io.ReadAll(m.Body.(io.ReadSeeker)) + // mbox.user.log.Printf("Body preview: \n\n %q \n\n", string(n)) + // m.Body.(io.ReadSeeker).Seek(0, io.SeekStart) + + m.Body, err = e2ee.DecryptFilter(m.Body.(io.ReadSeekCloser), mbox.user.session.Decrypt) + if err != nil { + mbox.user.log.Println("decrypter error: ", err) + return err + } + + msg, err := mbox.fetchMessage(mseq, m, items) + if err != nil { + continue + } + ch <- msg + } + return nil +} + +func (mbox *Mailbox) fetchMessage(seqNum int, m *types.Mail, items []imap.FetchItem) (*imap.Message, error) { + fetched := imap.NewMessage(uint32(m.ID), items) + fetched.SeqNum = uint32(seqNum) + fetched.Uid = uint32(m.ID) + + if !mbox.needsBody(items) || m.Body == nil { + return mbox.fillMetadataOnly(fetched, m, items), nil + } + + defer m.Body.Close() + + br := bufio.NewReader(m.Body) + hdr, hdrErr := textproto.ReadHeader(br) + if hdrErr != nil { + return mbox.fillMetadataOnly(fetched, m, items), nil + } + + for _, item := range items { + switch item { + case imap.FetchEnvelope: + fetched.Envelope, _ = backendutil.FetchEnvelope(hdr) + case imap.FetchBody, imap.FetchBodyStructure: + fetched.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, br, item == imap.FetchBodyStructure) + case imap.FetchRFC822Size: + fetched.Size = uint32(m.Size) + case imap.FetchInternalDate: + fetched.InternalDate = m.Date + case imap.FetchFlags: + fetched.Flags = mbox.formatFlags(m) + default: + if section, err := imap.ParseBodySectionName(item); err == nil { + l, _ := backendutil.FetchBodySection(hdr, br, section) + fetched.Body[section] = l + } + } + } + + return fetched, nil +} + +func (mbox *Mailbox) needsBody(items []imap.FetchItem) bool { + for _, item := range items { + switch item { + case imap.FetchEnvelope, imap.FetchBody, imap.FetchBodyStructure: + return true + default: + if _, err := imap.ParseBodySectionName(item); err == nil { + return true + } + } + } + return false +} + +func (mbox *Mailbox) fillMetadataOnly(dest *imap.Message, m *types.Mail, items []imap.FetchItem) *imap.Message { + for _, item := range items { + switch item { + case imap.FetchFlags: + dest.Flags = mbox.formatFlags(m) + case imap.FetchInternalDate: + dest.InternalDate = m.Date + case imap.FetchRFC822Size: + dest.Size = uint32(m.Size) + } + } + return dest +} + +func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { + id, err := mbox.storage.MailCreate(mbox.user.username, mbox.name, body, body.Len()) + if err != nil { + return err + } + + seen, answered, flagged, deleted := mbox.parseFlags(flags) + return mbox.storage.MailUpdateFlags(mbox.user.username, mbox.name, id, seen, answered, flagged, deleted) +} + +func (mbox *Mailbox) CopyMessages(uid bool, seqSet *imap.SeqSet, destName string) error { + pk := mbox.user.username + ids, err := mbox.getIDsFromSeqSet(uid, seqSet) + if err != nil { + return err + } + + for _, id := range ids { + _, mail, err := mbox.storage.MailSelect(pk, mbox.name, int(id)) + if err != nil { + continue + } + + newID, err := mbox.storage.MailCreate(pk, destName, mail.Body, int(mail.Size)) + mail.Body.Close() + if err == nil { + _ = mbox.storage.MailUpdateFlags(pk, destName, newID, mail.Seen, mail.Answered, mail.Flagged, mail.Deleted) + } + } + return nil +} + +func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqSet *imap.SeqSet, op imap.FlagsOp, flags []string) error { + pk := mbox.user.username + ids, err := mbox.getIDsFromSeqSet(uid, seqSet) + if err != nil { + return err + } + + for _, id := range ids { + _, mail, err := mbox.storage.MailSelect(pk, mbox.name, int(id)) + if err != nil { + continue + } + mail.Body.Close() // Close body. Read Flags + + seen, ans, flg, del := mbox.calculateNewFlags(mail, op, flags) + _ = mbox.storage.MailUpdateFlags(pk, mbox.name, int(id), seen, ans, flg, del) + } + return nil +} + +// calculateNewFlags выносит логику битовых (флаговых) операций из общего цикла +func (mbox *Mailbox) calculateNewFlags(mail *types.Mail, op imap.FlagsOp, flags []string) (s, a, f, d bool) { + s, a, f, d = mail.Seen, mail.Answered, mail.Flagged, mail.Deleted + ns, na, nf, nd := mbox.parseFlags(flags) + + switch op { + case imap.SetFlags: + s, a, f, d = ns, na, nf, nd + case imap.AddFlags: + if ns { + s = true + } + if na { + a = true + } + if nf { + f = true + } + if nd { + d = true + } + case imap.RemoveFlags: + if ns { + s = false + } + if na { + a = false + } + if nf { + f = false + } + if nd { + d = false + } + } + return +} + +func (mbox *Mailbox) parseFlags(flags []string) (s, a, f, d bool) { + for _, flag := range flags { + switch flag { + case "\\Seen": + s = true + case "\\Answered": + a = true + case "\\Flagged": + f = true + case "\\Deleted": + d = true + } + } + return +} + +func (mbox *Mailbox) formatFlags(mail *types.Mail) []string { + f := []string{} + if mail.Seen { + f = append(f, "\\Seen") + } + if mail.Answered { + f = append(f, "\\Answered") + } + if mail.Flagged { + f = append(f, "\\Flagged") + } + if mail.Deleted { + f = append(f, "\\Deleted") + } + return f +} + func (mbox *Mailbox) getIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]int32, error) { var ids []int32 + pk := mbox.user.username for _, set := range seqSet.Set { - if set.Stop == 0 { - next, err := mbox.backend.Storage.MailNextID(mbox.name) - if err != nil { - return nil, fmt.Errorf("mbox.backend.Storage.MailNextID: %w", err) - } - set.Stop = uint32(next - 1) + stop := set.Stop + if stop == 0 { + // In IMAP 0 = All + next, _ := mbox.storage.MailNextID(pk, mbox.name) + stop = uint32(next - 1) } - for i := set.Start; i <= set.Stop; i++ { + for i := set.Start; i <= stop; i++ { if !uid { - pid, err := mbox.backend.Storage.MailIDForSeq(mbox.name, int(i)) - if err != nil { - return nil, fmt.Errorf("mbox.backend.Storage.MailIDForSeq: %w", err) + // Convert uid by db + pid, err := mbox.storage.MailIDForSeq(pk, mbox.name, int(i)) + if err == nil { + ids = append(ids, int32(pid)) } - ids = append(ids, int32(pid)) } else { ids = append(ids, int32(i)) } @@ -52,283 +313,31 @@ func (mbox *Mailbox) getIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]int32, e 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.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.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.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.name, subscribed) + return mbox.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 { - return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) - } - - for _, id := range ids { - mseq, mail, err := mbox.backend.Storage.MailSelect(mbox.name, int(id)) - if err != nil { - continue - } - - fetched := imap.NewMessage(uint32(id), items) - fetched.SeqNum = uint32(mseq) - fetched.Uid = uint32(mail.ID) - - get := func() (io.Reader, textproto.Header, error) { - bodyreader := bufio.NewReader(bytes.NewReader(mail.Mail)) - 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 mail.Seen { - fetched.Flags = append(fetched.Flags, "\\Seen") - } - if mail.Answered { - fetched.Flags = append(fetched.Flags, "\\Answered") - } - if mail.Flagged { - fetched.Flags = append(fetched.Flags, "\\Flagged") - } - if mail.Deleted { - fetched.Flags = append(fetched.Flags, "\\Deleted") - } - - case imap.FetchInternalDate: - fetched.InternalDate = mail.Date - - case imap.FetchRFC822Size: - fetched.Size = uint32(len(mail.Mail)) - - 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) Check() error { return nil } func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { - return mbox.backend.Storage.MailSearch(mbox.name) -} - -func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { - b, err := io.ReadAll(body) - if err != nil { - return fmt.Errorf("b.ReadFrom: %w", err) - } - id, err := mbox.backend.Storage.MailCreate(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.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 mail *types.Mail - if op != imap.SetFlags { - var err error - _, mail, err = mbox.backend.Storage.MailSelect(mbox.name, int(id)) - if err != nil { - return fmt.Errorf("mbox.backend.Storage.MailSelect: %w", err) - } - } - for _, flag := range flags { - switch flag { - case "\\Seen": - mail.Seen = op != imap.RemoveFlags - case "\\Answered": - mail.Answered = op != imap.RemoveFlags - case "\\Flagged": - mail.Flagged = op != imap.RemoveFlags - case "\\Deleted": - mail.Deleted = op != imap.RemoveFlags - } - } - - if err := mbox.backend.Storage.MailUpdateFlags( - mbox.name, int(mail.ID), mail.Seen, - mail.Answered, mail.Flagged, mail.Deleted, - ); err != nil { - return err - } - } - return nil -} - -func (mbox *Mailbox) CopyMessages(uid bool, seqSet *imap.SeqSet, destName string) error { - if destName == "Outbox" { - return fmt.Errorf("can't copy into Outbox as it is a protected folder") - } - - ids, err := mbox.getIDsFromSeqSet(uid, seqSet) - if err != nil { - return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) - } - - for _, id := range ids { - _, mail, err := mbox.backend.Storage.MailSelect(mbox.name, int(id)) - if err != nil { - return fmt.Errorf("mbox.backend.Storage.MailSelect: %w", err) - } - pid, err := mbox.backend.Storage.MailCreate(destName, mail.Mail) - if err != nil { - return fmt.Errorf("mbox.backend.Storage.MailCreate: %w", err) - } - if err = mbox.backend.Storage.MailUpdateFlags( - mbox.name, pid, mail.Seen, mail.Answered, mail.Flagged, mail.Deleted, - ); err != nil { - return fmt.Errorf("mbox.backend.Storage.MailUpdateFlags: %w", err) - } - } - return nil + return mbox.storage.MailSearch(mbox.user.username, mbox.name) } func (mbox *Mailbox) Expunge() error { - return mbox.backend.Storage.MailExpunge(mbox.name) + return mbox.storage.MailExpunge(mbox.user.username, mbox.name) } func (mbox *Mailbox) MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error { - if dest == "Outbox" { - return fmt.Errorf("can't copy into Outbox as it is a protected folder") - } - + pk := mbox.user.username ids, err := mbox.getIDsFromSeqSet(uid, seqset) if err != nil { - return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) + return err } - for _, id := range ids { - if err := mbox.backend.Storage.MailMove(mbox.name, int(id), dest); err != nil { - return err - } - if mbox.name == "Outbox" { - if err := mbox.backend.Storage.QueueDeleteDestinationForID("Outbox", int(id)); err != nil { - return err + if nid, err := mbox.storage.MailMove(pk, mbox.name, int(id), dest); err == nil { + // Delete mail from Outbox before send in Queue + if mbox.name == "Outbox" { + _ = mbox.storage.QueueDeleteDestinationForID(mbox.user.Username(), "Outbox", int(nid)) } } } diff --git a/internal/imapserver/notify.go b/internal/imapserver/notify.go index d1f0b4b..410b638 100644 --- a/internal/imapserver/notify.go +++ b/internal/imapserver/notify.go @@ -9,65 +9,41 @@ package imapserver import ( - "fmt" - "log" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/server" + "github.com/emersion/go-imap/backend" + "github.com/neilalexander/yggmail/internal/notify" ) -type IMAPNotifyHandler struct { - imap.Command -} +// github.com/emersion/go-imap/backend/updates.go +func (b *Backend) listenUpdates() { + allEvents := b.Notify.Subscribe(notify.AllUsers) -func (h *IMAPNotifyHandler) Handle(conn server.Conn) error { - // TODO: Support setting NOTIFY subscriptions or not - return nil -} - -type IMAPNotify struct { - server *server.Server - log *log.Logger -} - -func (ext *IMAPNotify) Capabilities(c server.Conn) []string { - if c.Context().State&imap.AuthenticatedState != 0 { - return []string{"NOTIFY"} - } - return nil -} - -func (ext *IMAPNotify) Command(name string) server.HandlerFactory { - if name != "NOTIFY" { - return nil - } - return func() server.Handler { - return &IMAPNotifyHandler{} - } -} - -func (ext *IMAPNotify) NotifyNew(id, count int) error { - ext.server.ForEachConn(func(c server.Conn) { - var resptype imap.StatusRespType - if mailbox := c.Context().Mailbox; mailbox != nil && mailbox.Name() == "INBOX" { - resptype = imap.StatusRespType( - fmt.Sprintf("EXISTS %d", id), - ) - } else { - resptype = imap.StatusRespType( - fmt.Sprintf("STATUS INBOX (UIDNEXT %d MESSAGES %d)", id+1, count), - ) + go func() { + for event := range allEvents { + if ev, ok := event.(*notify.EmailEvent); ok { + // b.updates <- &backend.MailboxUpdate{ + // Update: backend.NewUpdate(ev.GetAccountPK(), ev.Mailbox()), + // MailboxStatus: &imap.MailboxStatus{ + // Name: ev.MailBox, + // Messages: uint32(ev.Count), + // UidNext: uint32(ev.MessageId), + // }, + // } + // b.updates <- &backend.MessageUpdate{ + // Update: backend.NewUpdate(ev.AccountPK, ev.MailBox), + // Message: imap.NewMessage(uint32(ev.Count), []imap.FetchItem{imap.FetchAll}), + // } + b.updates <- &backend.MailboxUpdate{ + Update: backend.NewUpdate(ev.GetAccountPK(), ev.MailBox), + MailboxStatus: &imap.MailboxStatus{ + Name: ev.MailBox, + Messages: uint32(ev.Count), + UidNext: uint32(ev.MessageId), + Items: map[imap.StatusItem]interface{}{imap.StatusMessages: "", imap.StatusUidNext: ev.Count}, + }, + } + b.Log.Println("IMAP Notify: ", ev.Username()) + } } - _ = c.WriteResp(&imap.StatusResp{ - Type: resptype, - }) - }) - return nil -} - -func NewIMAPNotify(s *server.Server, log *log.Logger) *IMAPNotify { - return &IMAPNotify{ - server: s, - log: log, - } + }() } diff --git a/internal/imapserver/user.go b/internal/imapserver/user.go index 29b0c77..faf1893 100644 --- a/internal/imapserver/user.go +++ b/internal/imapserver/user.go @@ -9,35 +9,38 @@ package imapserver import ( - "encoding/hex" "errors" "fmt" "log" "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" + "github.com/neilalexander/yggmail/internal/account" + "github.com/neilalexander/yggmail/internal/storage" ) type User struct { - backend *Backend + //backend *Backend + storage storage.Storage username string + session *account.Session conn *imap.ConnInfo - log *log.Logger + log *log.Logger } func (u *User) Username() string { - return hex.EncodeToString(u.backend.Config.PublicKey) + return u.session.Username() } func (u *User) ListMailboxes(subscribed bool) (mailboxes []backend.Mailbox, err error) { - names, err := u.backend.Storage.MailboxList(subscribed) + names, err := u.storage.MailboxList(u.username, subscribed) if err != nil { return nil, err } for _, mailbox := range names { mailboxes = append(mailboxes, &Mailbox{ - backend: u.backend, + storage: u.storage, user: u, name: mailbox, }) @@ -49,17 +52,17 @@ func (u *User) ListMailboxes(subscribed bool) (mailboxes []backend.Mailbox, err func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) { if name == "" { return &Mailbox{ - backend: u.backend, + storage: u.storage, user: u, name: "", }, nil } - ok, _ := u.backend.Storage.MailboxSelect(name) + ok, _ := u.storage.MailboxSelect(u.username, name) if !ok { return nil, fmt.Errorf("mailbox %q not found", name) } return &Mailbox{ - backend: u.backend, + storage: u.storage, user: u, name: name, }, nil @@ -67,14 +70,14 @@ func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) { func (u *User) CreateMailbox(name string) error { u.log.Printf("Creating mailbox '%s'...\n", name) - - if e := u.backend.Storage.MailboxCreate(name); e != nil { - u.log.Printf("Error creating mailbox '%s': %v\n", name, e); - return e; + + if e := u.storage.MailboxCreate(u.username, name); e != nil { + u.log.Printf("Error creating mailbox '%s': %v\n", name, e) + return e } - - u.log.Printf("Created mailbox '%s'\n", name); - return nil; + + u.log.Printf("Created mailbox '%s'\n", name) + return nil } func (u *User) DeleteMailbox(name string) error { @@ -82,12 +85,12 @@ func (u *User) DeleteMailbox(name string) error { case "INBOX", "Outbox", "Sent": return errors.New("Cannot delete " + name) default: - if e := u.backend.Storage.MailboxDelete(name); e != nil { - u.log.Printf("Error deleting mailbox '%s': %v\n", name, e) - return e; + if e := u.storage.MailboxDelete(u.username, name); e != nil { + u.log.Printf("Error deleting mailbox '%s': %v\n", name, e) + return e } else { u.log.Printf("Deleted mailbox '%s'\n", name) - return e; + return e } } } @@ -97,7 +100,7 @@ func (u *User) RenameMailbox(existingName, newName string) error { case "INBOX", "Outbox", "Sent": return errors.New("Cannot rename " + existingName) default: - return u.backend.Storage.MailboxRename(existingName, newName) + return u.storage.MailboxRename(u.username, existingName, newName) } } diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..a3999a4 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package notify + +import ( + "log" + "sync" +) + +type EventType int + +const ( + EventMailNew EventType = iota + EventMailUpdate + EventMailPurge + EventFlagsUpdate + EventHeartbit + // ... +) +const AllUsers = "*" + +type Event interface { + GetType() EventType + GetAccountPK() string +} +type HeartbitEvent struct { + Type EventType + AccountPK string +} + +func (e *HeartbitEvent) GetAccountPK() string { return e.AccountPK } +func (e *HeartbitEvent) GetType() EventType { return EventHeartbit } + +type EmailEvent struct { + Type EventType + MessageId, Count int64 + AccountPK, MailBox string + done chan struct{} +} + +func NewEmailEvent(pk, mailbox string, id, count int64) *EmailEvent { + return &EmailEvent{ + AccountPK: pk, + MailBox: mailbox, + MessageId: id, + Count: count, + done: make(chan struct{}), + } +} + +func (e *EmailEvent) GetAccountPK() string { return e.AccountPK } +func (e *EmailEvent) GetType() EventType { return e.Type } + +// imap/backend/update interface + +func (e *EmailEvent) Username() string { return e.AccountPK } +func (e *EmailEvent) Mailbox() string { return e.MailBox } +func (e *EmailEvent) Done() chan struct{} { return e.done } + +type FlagsEvent struct { + AccountPK string + Mailbox string + MessageID int64 + Seen bool + Answered bool + Flagged bool + Deleted bool +} + +func (e *FlagsEvent) GetAccountPK() string { return e.AccountPK } +func (e *FlagsEvent) GetType() EventType { return EventFlagsUpdate } + +type Notify struct { + log *log.Logger + + mu sync.Mutex + listeners map[string]map[chan Event]struct{} +} + +func NewNotifyBus(log *log.Logger) *Notify { + return &Notify{ + log: log, + } +} + +// Subscribe for any methods +func (n *Notify) Subscribe(pk string) chan Event { + n.mu.Lock() + defer n.mu.Unlock() + + if n.listeners == nil { + n.listeners = make(map[string]map[chan Event]struct{}) + } + if n.listeners[pk] == nil { + n.listeners[pk] = make(map[chan Event]struct{}) + } + + ch := make(chan Event, 10) + n.listeners[pk][ch] = struct{}{} + return ch +} + +func (n *Notify) Unsubscribe(pk string, ch chan Event) { + n.mu.Lock() + defer n.mu.Unlock() + + if subs, ok := n.listeners[pk]; ok { + delete(subs, ch) + if len(subs) == 0 { + delete(n.listeners, pk) + } + } +} + +// Do Notification for clients +func (n *Notify) NotifyNew(pk string, event Event) error { + n.mu.Lock() + defer n.mu.Unlock() + go func() { + if subs, ok := n.listeners[pk]; ok { + for ch := range subs { + ch <- event + } + } + if subs, ok := n.listeners[AllUsers]; ok { + for ch := range subs { + ch <- event + } + } + }() + return nil +} diff --git a/internal/notify/remote/remote.go b/internal/notify/remote/remote.go new file mode 100644 index 0000000..6e04778 --- /dev/null +++ b/internal/notify/remote/remote.go @@ -0,0 +1,78 @@ + +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "crypto/ed25519" + "fmt" + + "github.com/neilalexander/yggmail/internal/notify" + "github.com/neilalexander/yggmail/internal/remote" + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/transport" + "github.com/neilalexander/yggmail/internal/utils" +) + +type RemoteNotifer struct { + Client *remote.Client + targetNode ed25519.PublicKey + userPk ed25519.PublicKey +} + +func NewRemoteNotifer(targetNode, userPk ed25519.PublicKey, signer remote.Signer, tr transport.Transport) *RemoteNotifer { + return &RemoteNotifer{ + Client: remote.NewClient(userPk, signer, tr), + targetNode: targetNode, + userPk: userPk, + } +} +func (n *RemoteNotifer) Watch(pk, mailbox string) (uint32, uint32, error) { + resp, _, err := n.Client.Call(n.targetNode, &pb.Request{ + Call: &pb.Request_MailWatch{ + MailWatch: &pb.WatchRequest{Mailbox: mailbox}, + }, + }, nil, 0) + if err != nil { + return 0, 0, err + } + if !resp.Success { + return 0, 0, fmt.Errorf("remote error: %s", resp.Error) + } + if resp.Error == "HEARTBIT" { + return 0, 0, nil + } + + status := resp.GetMboxStatus() + if status != nil { + return status.UidNext, status.MessagesCount, nil + } + return 0, 0, nil +} + +func (n *RemoteNotifer) StartRemoteNotifer(nf *notify.Notify, pk ed25519.PublicKey, mailbox string) { + go func() { + for { + pk := utils.EncodeString(n.userPk) + uidNext, count, err := n.Watch(pk, mailbox) + if err != nil { + continue + } + if uidNext == 0 && count == 0 && err == nil { + // fmt.Println("RPC Notify: HEARTBIT: Pk", pk) + // Heartbit: Update connection + continue + } + if nf != nil { + fmt.Println("RPC Notify: Notify: Pk", pk) + ev := notify.NewEmailEvent(pk, mailbox, int64(uidNext), int64(count)) + go nf.NotifyNew(pk, ev) + } + } + }() +} diff --git a/internal/remote/client.go b/internal/remote/client.go new file mode 100644 index 0000000..07b3b4f --- /dev/null +++ b/internal/remote/client.go @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package remote + +import ( + "bufio" + "crypto/ed25519" + "encoding/hex" + "fmt" + "io" + "net" + "time" + + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/transport" + "github.com/neilalexander/yggmail/internal/utils" +) + +type rpcSession struct { + conn net.Conn + buf *bufio.Reader +} + +type Client struct { + publicKey ed25519.PublicKey + signer Signer + transport transport.Transport + + pool chan *rpcSession +} + +func NewClient(pubkey ed25519.PublicKey, signer Signer, tr transport.Transport) *Client { + c := &Client{ + publicKey: pubkey, + signer: signer, + transport: tr, + pool: make(chan *rpcSession, 3), + } + return c +} + +func (c *Client) getConn(target ed25519.PublicKey) (*rpcSession, error) { + select { + case session := <-c.pool: + return session, nil + default: + conn, err := c.transport.Dial(hex.EncodeToString(target)) + if err != nil { + return nil, err + } + session := &rpcSession{ + conn: conn, + buf: bufio.NewReader(conn), + } + return session, nil + } +} +func (c *Client) returnConn(sess *rpcSession) { + select { + case c.pool <- sess: + default: + sess.conn.Close() + } +} + +func (c *Client) Call(targetNode ed25519.PublicKey, req *pb.Request, payload io.ReadCloser, size uint32) (*pb.Response, *Payload, error) { + for range 2 { + sess, err := c.getConn(targetNode) + if err != nil { + return nil, nil, fmt.Errorf("dial failed: %w", err) + } + + req.AccountPk = utils.EncodeString(c.publicKey) + req.Timestamp = time.Now().UnixNano() + + if err := SignRequest(req, c.signer); err != nil { + c.returnConn(sess) + return nil, nil, err + } + + resp, respPayload, err := c.doRPC(sess, req, payload, size) + if err != nil { + sess.conn.Close() + return nil, nil, fmt.Errorf("rpc failed: %w", err) + } + + if respPayload != nil && respPayload.R != nil { + respPayload.R.Close() + } + + c.returnConn(sess) + return resp, respPayload, nil + } + return nil, nil, fmt.Errorf("all retries failed") +} + +func (c *Client) doRPC(sess *rpcSession, req *pb.Request, payload io.ReadCloser, size uint32) (*pb.Response, *Payload, error) { + p := &Payload{ + R: payload, + Size: size, + } + + if err := SendFrame(sess.conn, req, p); err != nil { + return nil, nil, err + } + + return ReadResponse(sess.buf) +} diff --git a/internal/remote/handlers.go b/internal/remote/handlers.go new file mode 100644 index 0000000..3464cf8 --- /dev/null +++ b/internal/remote/handlers.go @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package remote + +import ( + "bytes" + "context" + "encoding/hex" + "io" + "time" + + "github.com/neilalexander/yggmail/internal/notify" + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/utils" +) + +// --- MAILBOXES --- + +func (srv *Server) handleMailboxList(ctx context.Context, pk string, call *pb.MailboxListRequest) (*pb.Response, error) { + list, err := srv.storage.MailboxList(pk, call.OnlySubscribed) + if err != nil { + return nil, err + } + return &pb.Response{Success: true, Data: &pb.Response_MboxList{MboxList: &pb.MailboxList{Names: list}}}, nil +} + +func (srv *Server) handleMailboxCreate(ctx context.Context, pk string, call *pb.MailboxCreateRequest) (*pb.Response, error) { + err := srv.storage.MailboxCreate(pk, call.Name) + srv.log.Printf("RPC Server:: MailboxCreate: Name: %s: Pk: %s", call.Name, pk) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailboxDelete(ctx context.Context, pk string, call *pb.MailboxDeleteRequest) (*pb.Response, error) { + err := srv.storage.MailboxDelete(pk, call.Name) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailboxRename(ctx context.Context, pk string, call *pb.MailboxRenameRequest) (*pb.Response, error) { + err := srv.storage.MailboxRename(pk, call.OldName, call.NewName) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailboxSubscribe(ctx context.Context, pk string, call *pb.MailboxSubscribeRequest) (*pb.Response, error) { + err := srv.storage.MailboxSubscribe(pk, call.Name, call.Subscribed) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailboxStatus(ctx context.Context, pk string, call *pb.MailboxStatusRequest) (*pb.Response, error) { + exists, err := srv.storage.MailboxSelect(pk, call.Name) + if err != nil || !exists { + return &pb.Response{Success: false, Error: "Mailbox not found"}, nil + } + + count, _ := srv.storage.MailCount(pk, call.Name) + unseen, _ := srv.storage.MailUnseen(pk, call.Name) + nextID, _ := srv.storage.MailNextID(pk, call.Name) + + return &pb.Response{ + Success: true, + Data: &pb.Response_MboxStatus{ + MboxStatus: &pb.MailboxStatus{ + Name: call.Name, MessagesCount: uint32(count), UnseenCount: uint32(unseen), UidNext: uint32(nextID), UidValidity: 1, + }, + }, + }, nil +} + +// --- MAILS --- + +// handleMailFetch processes fetching metadata and optionally streams a single message body. +func (srv *Server) handleMailFetch(ctx context.Context, pk string, call *pb.MailFetchRequest) (*pb.Response, *Payload, error) { + pbList := &pb.MailList{} + var messagePayload *Payload + + for _, id := range call.Ids { + seq, mail, err := srv.storage.MailSelect(pk, call.Mailbox, int(id)) + if err != nil { + continue + } + + pbList.Items = append(pbList.Items, &pb.MailMessage{ + Id: uint32(mail.ID), + Seq: uint32(seq), + Date: mail.Date.Unix(), + Seen: mail.Seen, + Answered: mail.Answered, + Flagged: mail.Flagged, + Deleted: mail.Deleted, + Size: uint32(mail.Size), + }) + + // If fetching a single message, prepare the stream for the body payload + if len(call.Ids) == 1 && mail.Body != nil { + messagePayload = &Payload{ + R: mail.Body, + Size: uint32(mail.Size), + } + } else if mail.Body != nil { + mail.Body.Close() + } + } + + return &pb.Response{ + Success: true, + Data: &pb.Response_MailList{MailList: pbList}, + }, messagePayload, nil +} + +func (srv *Server) handleMailFlags(ctx context.Context, pk string, call *pb.MailUpdateFlagsRequest) (*pb.Response, error) { + err := srv.storage.MailUpdateFlags(pk, call.Mailbox, int(call.Id), call.Seen, call.Answered, call.Flagged, call.Deleted) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailMove(ctx context.Context, pk string, call *pb.MailMoveRequest) (*pb.Response, error) { + _, err := srv.storage.MailMove(pk, call.Mailbox, int(call.Id), call.DestinationMailbox) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailDelete(ctx context.Context, pk string, call *pb.MailDeleteRequest) (*pb.Response, error) { + err := srv.storage.MailDelete(pk, call.Mailbox, int(call.Id)) + return &pb.Response{Success: err == nil}, err +} + +func (srv *Server) handleMailExpunge(ctx context.Context, pk string, call *pb.MailExpungeRequest) (*pb.Response, error) { + err := srv.storage.MailExpunge(pk, call.Mailbox) + return &pb.Response{Success: err == nil}, err +} + +// --- QUEUE & EXCHANGE --- + +func (srv *Server) handleQueueInsert(ctx context.Context, pk string, call *pb.QueueInsertRequest) (*pb.Response, error) { + err := srv.storage.QueueInsertDestinationForID(pk, call.Destination, int(call.Id), call.From, call.Rcpt) + return &pb.Response{Success: err == nil}, err +} + +// handleMailPush handles incoming mail streams and routes them (Incoming, Outgoing, or Save). +func (srv *Server) handleMailPush(ctx context.Context, senderPkStr string, call *pb.MailPushRequest, payload *Payload) (*pb.Response, error) { + if payload == nil || payload.R == nil { + return &pb.Response{Success: false, Error: "Empty payload"}, nil + } + defer payload.R.Close() + + senderPkBytes, _ := utils.DecodeString(senderPkStr) + fromDom, fromAcc, _ := utils.ParseAddress(call.From) + toDom, toAcc, _ := utils.ParseAddress(call.To) + + isFromMe := bytes.Equal(senderPkBytes, fromAcc) && fromDom.Equal(srv.nodePublickey) + isFromPeerServer := bytes.Equal(senderPkBytes, fromDom) + isToMe := toDom.Equal(srv.nodePublickey) + + if isFromMe && call.Mailbox != "" { + return srv.saveToFolder(fromAcc, toDom, call.Mailbox, payload.R, payload.Size) + } + + if isFromMe { + return srv.processOutgoing(fromAcc, toDom, payload.R, payload.Size) + } + + if isFromPeerServer && isToMe { + accTo, _ := srv.accMgr.GetAccount(utils.EncodeString(toAcc)) + if accTo != nil { + return srv.processIncoming(accTo.PublicKey, "INBOX", payload.R, payload.Size) + } + } + + return &pb.Response{Success: false, Error: "Relay access denied"}, nil +} + +func (srv *Server) processIncoming(accPK, mailbox string, content io.Reader, size uint32) (*pb.Response, error) { + id, err := srv.storage.MailCreate(accPK, mailbox, content, int(size)) + if err != nil { + return nil, err + } + if count, err := srv.storage.MailCount(accPK, mailbox); err == nil { + srv.notify.NotifyNew(accPK, notify.NewEmailEvent(accPK, mailbox, int64(id), int64(count))) + } + srv.log.Printf("RPC Server: MailPush: Incoming: Mailbox: %s for: %s\n", mailbox, accPK) + return &pb.Response{Success: true, Data: &pb.Response_IdResult{IdResult: uint32(id)}}, nil +} + +func (srv *Server) processOutgoing(accPK []byte, targetDom []byte, content io.Reader, size uint32) (*pb.Response, error) { + pkStr := utils.EncodeString(accPK) + _ = srv.storage.MailboxCreate(pkStr, "Outbox") + + id, err := srv.storage.MailCreate(pkStr, "Outbox", content, int(size)) + if err != nil { + return nil, err + } + + srv.log.Printf("RPC Server: MailPush: Outgoing: From: %s\n", accPK) + srv.queue.QueueForServer(hex.EncodeToString(targetDom)) + + return &pb.Response{Success: true, Data: &pb.Response_IdResult{IdResult: uint32(id)}}, nil +} + +func (srv *Server) saveToFolder(accPK []byte, targetDom []byte, mailbox string, content io.Reader, size uint32) (*pb.Response, error) { + pkStr := utils.EncodeString(accPK) + _ = srv.storage.MailboxCreate(pkStr, mailbox) + + id, err := srv.storage.MailCreate(pkStr, mailbox, content, int(size)) + if err != nil { + return nil, err + } + + // Trigger queue processing if saving directly to Outbox + if mailbox == "Outbox" { + srv.queue.QueueForServer(hex.EncodeToString(targetDom)) + } + + srv.log.Printf("RPC Server: MailPush: Save: Mailbox: %s for: %s\n", mailbox, pkStr) + return &pb.Response{Success: true, Data: &pb.Response_IdResult{IdResult: uint32(id)}}, nil +} + +// --- UTILS --- + +func (srv *Server) handleMailIdForSeq(ctx context.Context, pk string, call *pb.MailIdForSeqRequest) (*pb.Response, error) { + id, err := srv.storage.MailIDForSeq(pk, call.Mailbox, int(call.Seq)) + if err != nil { + return nil, err + } + + return &pb.Response{ + Success: true, + Data: &pb.Response_IdResult{IdResult: uint32(id)}, + }, nil +} + +func (srv *Server) handleMailSearch(ctx context.Context, pk string, call *pb.MailSearchRequest) (*pb.Response, error) { + ids, err := srv.storage.MailSearch(pk, call.Mailbox) + if err != nil { + return nil, err + } + + pbList := &pb.MailList{} + for _, id := range ids { + pbList.Items = append(pbList.Items, &pb.MailMessage{Id: id}) + } + + return &pb.Response{ + Success: true, + Data: &pb.Response_MailList{MailList: pbList}, + }, nil +} + +func (srv *Server) handleMailWatch(ctx context.Context, pk string, call *pb.WatchRequest) (*pb.Response, error) { + notifyChan := srv.notify.Subscribe(pk) + defer srv.notify.Unsubscribe(pk, notifyChan) + + select { + case ev := <-notifyChan: + return srv.handleNotify(pk, ev) + + case <-time.After(40 * time.Second): + return &pb.Response{Success: true, Error: "HEARTBEAT"}, nil + + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (srv *Server) handleNotify(pk string, event notify.Event) (*pb.Response, error) { + srv.log.Println("RPC: Notify Subscribe: ", pk) + switch ev := event.(type) { + case *notify.EmailEvent: + srv.log.Println("DEBUG:: RPC:: handleNotify:: Send:: EmailEvent: ", pk) + return &pb.Response{ + Success: true, + Data: &pb.Response_MboxStatus{ + MboxStatus: &pb.MailboxStatus{ + MessagesCount: uint32(ev.Count), + UidNext: uint32(ev.MessageId), + }, + }, + }, nil + case *notify.FlagsEvent: + return &pb.Response{ + Success: true, + Data: &pb.Response_MboxStatus{ + MboxStatus: &pb.MailboxStatus{}, + }, + }, nil + + default: + return &pb.Response{Success: true, Error: "HEARTBEAT"}, nil + } +} diff --git a/internal/remote/io.go b/internal/remote/io.go new file mode 100644 index 0000000..a1a741e --- /dev/null +++ b/internal/remote/io.go @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "sync" + + pb "github.com/neilalexander/yggmail/internal/remote/types" + "google.golang.org/protobuf/proto" +) + +var MagicHeader = []byte{0x59, 0x47, 0x47, 0x21} // "YGG!" +var MemoryLimit uint32 = 1024 * 1024 * 2 // 2 Mb + +type Payload struct { + R io.ReadCloser + Size uint32 +} + +func (p *Payload) Close() error { + if p == nil || p.R == nil { + return nil + } + return p.R.Close() +} + +type Tempfile struct { + io.ReadSeeker + file *os.File + size uint32 +} + +func (f *Tempfile) Len() int64 { + return int64(f.size) +} + +func (f *Tempfile) Close() error { + if f.file != nil { + f.file.Close() + return os.Remove(f.file.Name()) + } + return nil +} + +func ReadRawData(reader io.Reader) (io.ReadCloser, int, error) { + peekBuf := new(bytes.Buffer) + peekBuf.Grow(int(MemoryLimit)) + + limited := io.LimitReader(reader, int64(MemoryLimit)+1) + written, err := peekBuf.ReadFrom(limited) + if err != nil && err != io.EOF { + return nil, 0, fmt.Errorf("failed to read data: %w", err) + } + if written < int64(MemoryLimit) { + return &Tempfile{ + ReadSeeker: bytes.NewReader(peekBuf.Bytes()), + size: uint32(peekBuf.Len())}, peekBuf.Len(), nil + } + + // Sew data that we readed and not-read + reader = io.MultiReader(peekBuf, reader) + // Read large mail to temp file + payload, err := os.CreateTemp("", "yggmail-*") + if err != nil { + return nil, 0, fmt.Errorf("failed to create temp-data file: %w", err) + } + size, err := io.Copy(payload, reader) + payload.Seek(0, 0) + + return &Tempfile{payload, payload, uint32(size)}, int(size), nil +} + +// func ReadRawData(r io.Reader, rawLen uint32) (io.ReadCloser, error) { +// if rawLen == 0 { +// return nil, nil +// } +// +// if rawLen <= MemoryLimit { +// // Write in memory +// buf := bytes.NewBuffer(make([]byte, 0, rawLen)) +// _, err := io.CopyN(buf, r, int64(rawLen)) +// return io.NopCloser(buf), err +// } else { +// // Write to tmp +// tmp, _ := os.CreateTemp("", "yggmail-payload-*") +// _, err := io.CopyN(tmp, r, int64(rawLen)) +// tmp.Seek(0, 0) // Return in ahead +// return &Tempfile{File: tmp}, err +// } +// } + +// +// Frame = Magic + uint32(Len Protobuff Meta) + uint32(Len Payload) + Protobuff + Payload +// +// Magic Header +// +// len(Protobuff) +// len() +// +// Protobuff Metadata +// Payload Rawdata + +// # SendFrame +// +// Send Magic + uint32(Len Protobuff Meta) + uint32(Len Payload) + Protobuff + Payload +func readWithMagic(br *bufio.Reader, maxJunkBytes int) error { + magicIdx := 0 + + for range maxJunkBytes { + b, err := br.ReadByte() + if err != nil { + return err + } + + if b == MagicHeader[magicIdx] { + magicIdx++ + if magicIdx == len(MagicHeader) { + return nil // Found + } + } else { + if b == MagicHeader[0] { + magicIdx = 1 + } else { + magicIdx = 0 + } + } + } + return fmt.Errorf("sync failed: magic header not found") +} + +// # ReadFrame +// +// Magic + uint32(Len Protobuff Meta) + uint32(Len Payload) + Protobuff + Payload +// +// Return Response / Request Reader +// +// RawData Reader + +var marshalPool = sync.Pool{ + New: func() interface{} { + return make([]byte, 0, 1024*64) // Начинаем с 64КБ + }, +} + +type limitedReadCloser struct { + io.Reader + io.Closer +} + +func ReadFrame(br *bufio.Reader, msg proto.Message) (proto.Message, *Payload, error) { + if err := readWithMagic(br, 1024); err != nil { + return nil, nil, err + } + + var lengths [8]byte + if _, err := io.ReadFull(br, lengths[:]); err != nil { + return nil, nil, err + } + + protoLen := binary.BigEndian.Uint32(lengths[0:4]) + rawLen := binary.BigEndian.Uint32(lengths[4:8]) + + protoData := make([]byte, protoLen) + if _, err := io.ReadFull(br, protoData); err != nil { + return nil, nil, err + } + + if err := proto.Unmarshal(protoData, msg); err != nil { + return nil, nil, err + } + + var payload *Payload + if rawLen > 0 { + payload = &Payload{ + Size: rawLen, + } + + lr := io.LimitReader(br, int64(rawLen)) + if rc, ok := lr.(io.ReadCloser); ok { + payload.R = &limitedReadCloser{lr, rc} + } else { + payload.R = io.NopCloser(lr) + } + + } + return msg, payload, nil +} + +func SendFrame(w io.Writer, msg proto.Message, payload *Payload) error { + if payload != nil && payload.R != nil { + defer payload.R.Close() + } + + buf := marshalPool.Get().([]byte) + buf = buf[:0] + + opts := proto.MarshalOptions{} + data, err := opts.MarshalAppend(buf, msg) + if err != nil { + marshalPool.Put(buf) + return err + } + defer marshalPool.Put(data) + + pSize := uint32(0) + if payload != nil { + pSize = payload.Size + } + + var header [12]byte + copy(header[0:4], MagicHeader) + binary.BigEndian.PutUint32(header[4:8], uint32(len(data))) + binary.BigEndian.PutUint32(header[8:12], pSize) + + if _, err := w.Write(header[:]); err != nil { + return err + } + if _, err := w.Write(data); err != nil { + return err + } + + if payload != nil && payload.Size > 0 { + if _, err := io.CopyN(w, payload.R, int64(pSize)); err != nil { + return err + } + } + return nil +} + +func ReadRequest(r *bufio.Reader) (*pb.Request, *Payload, error) { + req := &pb.Request{} + _, payload, err := ReadFrame(r, req) + if err != nil { + return nil, nil, fmt.Errorf("ReadRequest: %w", err) + } + + if payload == nil { + return req, nil, nil + } + payload.R, _, err = ReadRawData(payload.R) + if err != nil { + return nil, nil, fmt.Errorf("Error read payload") + } + return req, payload, nil +} + +func ReadResponse(r *bufio.Reader) (*pb.Response, *Payload, error) { + resp := &pb.Response{} + _, payload, err := ReadFrame(r, resp) + if err != nil { + return nil, nil, fmt.Errorf("ReadRequest: %w", err) + } + + // Unmarshal Protobuff metadata + // Read File + if payload == nil { + return resp, nil, nil + } + payload.R, _, err = ReadRawData(payload.R) + if err != nil { + return nil, nil, fmt.Errorf("Error read payload") + } + return resp, payload, nil +} diff --git a/internal/remote/server.go b/internal/remote/server.go new file mode 100644 index 0000000..ffe310c --- /dev/null +++ b/internal/remote/server.go @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package remote + +import ( + "bufio" + "context" + "crypto/ed25519" + "fmt" + "io" + "log" + "net" + "time" + + "github.com/neilalexander/yggmail/internal/account" + "github.com/neilalexander/yggmail/internal/notify" + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/utils" +) + +type Server struct { + log *log.Logger + notify Notify + queue Queues + accMgr *account.Manager + storage storage.Storage + nodePublickey ed25519.PublicKey + validator *ReqValidator +} + +type Notify interface { + Subscribe(pbk string) chan notify.Event + Unsubscribe(pbk string, ch chan notify.Event) + NotifyNew(pk string, ev notify.Event) error +} + +type Queues interface { + QueueForServer(serverHex string) error +} + +func NewRemoteServer(nodePublickey ed25519.PublicKey, s storage.Storage, m *account.Manager, l *log.Logger, notify Notify, queue Queues) *Server { + return &Server{ + storage: s, + accMgr: m, + log: l, + notify: notify, + queue: queue, + nodePublickey: nodePublickey, + validator: NewValidator(time.Minute * 3), + } +} + +func (srv *Server) Serve(l net.Listener) error { + srv.log.Println("Unified Remote Storage RPC server started (Streaming Mode)") + for { + conn, err := l.Accept() + if err != nil { + continue + } + // Serving in new gorutine + go srv.handleSession(conn) + } +} + +func (srv *Server) handleSession(conn net.Conn) { + defer conn.Close() + br := bufio.NewReader(conn) + for { + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + req, reqPayload, err := ReadRequest(br) + if err != nil { + if err != io.EOF { + srv.log.Printf("RPC: Session error: %v", err) + } + return + } + + func() { + conn.SetDeadline(time.Now().Add(2 * time.Minute)) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2*time.Minute)) + defer cancel() + + resp, respPayload := srv.handleRequest(ctx, req, reqPayload) + + err = SendFrame(conn, resp, respPayload) + + if reqPayload != nil { + io.Copy(io.Discard, reqPayload.R) + reqPayload.Close() + } + if respPayload != nil { + respPayload.Close() + } + + if err != nil { + srv.log.Printf("RPC: Send error: %v", err) + return + } + }() + } +} +func RespErr(err error) *pb.Response { + return &pb.Response{Success: false, Error: err.Error()} +} + +func (srv *Server) handleRequest(ctx context.Context, req *pb.Request, reqPayload *Payload) (resp *pb.Response, respPayload *Payload) { + // srv.log.Printf("RPC: Request received from %s", req.AccountPk) + sPk, err := utils.DecodeString(req.AccountPk) + if err != nil { + return RespErr(fmt.Errorf("Error decode key")), respPayload + } + + // if ok, err := VerifyRequest(req, sPk); err != nil || !ok { + // return nil, fmt.Errorf("401 Unauthorized: Signature mismatch") + // } + // srv.log.Printf("RPC: Request Verifed from %s", req.AccountPk) + ok, err := srv.validator.Validate(req, sPk) + if err != nil { + return RespErr(fmt.Errorf("401 Unauthorized: Signature mismatch")), respPayload + } + if !ok { + return &pb.Response{Success: true, Error: "duplicate request ignored"}, respPayload + } + + // Check if this AccountPubKey belongs to a local account we manage + acc, _ := srv.accMgr.GetAccount(req.AccountPk) + isLocalAccount := (acc != nil) + + _, isPushCall := req.Call.(*pb.Request_MailPush) + if !isLocalAccount && !isPushCall { + return RespErr(fmt.Errorf("403 Forbidden: Remote management not allowed")), respPayload + } + + var rcname string + switch call := req.Call.(type) { + // --- Mailbox management (Local Account only via Policy) --- + case *pb.Request_MboxList: + resp, err = srv.handleMailboxList(ctx, req.AccountPk, call.MboxList) + rcname = "MboxList" + case *pb.Request_MboxCreate: + resp, err = srv.handleMailboxCreate(ctx, req.AccountPk, call.MboxCreate) + rcname = "MboxCreate" + case *pb.Request_MboxDelete: + resp, err = srv.handleMailboxDelete(ctx, req.AccountPk, call.MboxDelete) + rcname = "MboxDelete" + case *pb.Request_MboxRename: + resp, err = srv.handleMailboxRename(ctx, req.AccountPk, call.MboxRename) + rcname = "MboxRename" + case *pb.Request_MboxSub: + resp, err = srv.handleMailboxSubscribe(ctx, req.AccountPk, call.MboxSub) + rcname = "MboxSub" + case *pb.Request_MboxStatus: + resp, err = srv.handleMailboxStatus(ctx, req.AccountPk, call.MboxStatus) + rcname = "MboxStatus" + + // --- Mail management --- + case *pb.Request_MailPush: + // handleMailPush has its own logic for Case A (Local) and Case B (Remote Node) + resp, err = srv.handleMailPush(ctx, req.AccountPk, call.MailPush, reqPayload) + rcname = "MailPush" + + case *pb.Request_MailFetch: + resp, respPayload, err = srv.handleMailFetch(ctx, req.AccountPk, call.MailFetch) + rcname = "MailFetch" + case *pb.Request_MailFlags: + resp, err = srv.handleMailFlags(ctx, req.AccountPk, call.MailFlags) + rcname = "MailFlags" + case *pb.Request_MailMove: + resp, err = srv.handleMailMove(ctx, req.AccountPk, call.MailMove) + rcname = "MailMove" + case *pb.Request_MailDel: + resp, err = srv.handleMailDelete(ctx, req.AccountPk, call.MailDel) + rcname = "MailDel" + case *pb.Request_MailExpunge: + resp, err = srv.handleMailExpunge(ctx, req.AccountPk, call.MailExpunge) + rcname = "MailExpunge" + case *pb.Request_MailIdForSeq: + resp, err = srv.handleMailIdForSeq(ctx, req.AccountPk, call.MailIdForSeq) + rcname = "MailIdForSeq" + case *pb.Request_MailSearch: + resp, err = srv.handleMailSearch(ctx, req.AccountPk, call.MailSearch) + rcname = "MailSearch" + case *pb.Request_MailWatch: + resp, err = srv.handleMailWatch(ctx, req.AccountPk, call.MailWatch) + + // --- Queue management (Local Account only via Policy) --- + case *pb.Request_QueueInsert: + resp, err = srv.handleQueueInsert(ctx, req.AccountPk, call.QueueInsert) + rcname = "QueueInsert" + + default: + rcname = "Not Implemented" + resp = &pb.Response{Success: false, Error: "501 Not Implemented"} + } + // srv.log.Printf("RPC: %s : Authorized: %t: Publickey: %s :", rcname, isLocalAccount, req.AccountPk) + + if err != nil { + srv.log.Printf("RPC Error: %s %v", rcname, err) + resp = &pb.Response{Success: false, Error: err.Error()} + } + return resp, respPayload +} diff --git a/internal/remote/sign.go b/internal/remote/sign.go new file mode 100644 index 0000000..7604e1e --- /dev/null +++ b/internal/remote/sign.go @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "crypto/ed25519" + "fmt" + "sync" + "time" + + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/utils" + "google.golang.org/protobuf/proto" +) + +type ReqValidator struct { + r map[string]time.Time + m sync.RWMutex + window time.Duration +} + +func NewValidator(window time.Duration) *ReqValidator { + r := &ReqValidator{ + window: window, + r: map[string]time.Time{}, + } + go r.Cleaner() + return r +} +func (rv *ReqValidator) Validate(req *pb.Request, pub ed25519.PublicKey) (bool, error) { + diff := time.Since(time.Unix(0, req.Timestamp)) + if diff < 0 { + diff = -diff + } + if diff > rv.window { + return false, fmt.Errorf("request timestamp is too old or too far in future") + } + + ok, err := VerifyRequest(req, pub) + if err != nil || !ok { + return false, err + } + rv.m.Lock() + defer rv.m.Unlock() + + if _, exist := rv.r[utils.EncodeString(req.Signature)]; exist { + return false, nil + } + rv.r[utils.EncodeString(req.Signature)] = time.Now() + return true, nil +} + +func (rv *ReqValidator) Cleaner() { + for tn := range time.NewTicker(time.Minute).C { + rv.clean(tn) + } +} + +func (rv *ReqValidator) clean(tn time.Time) { + rv.m.Lock() + defer rv.m.Unlock() + for s, tr := range rv.r { + if tr.Add(rv.window).Before(tn) { + delete(rv.r, s) + } + } +} + +type Signer func([]byte) ([]byte, error) + +func PrepareToSign(req *pb.Request) ([]byte, error) { + clone := proto.Clone(req).(*pb.Request) + + clone.Signature = nil + + opts := proto.MarshalOptions{Deterministic: true} + return opts.Marshal(clone) +} + +func SignRequest(req *pb.Request, signer Signer) error { + data, err := PrepareToSign(req) + if err != nil { + return err + } + + req.Signature, err = signer(data) + if err != nil { + return err + } + return nil +} + +func VerifyRequest(req *pb.Request, pubKey ed25519.PublicKey) (bool, error) { + if len(req.Signature) == 0 { + return false, fmt.Errorf("missing signature") + } + data, err := PrepareToSign(req) + if err != nil { + return false, err + } + if !ed25519.Verify(pubKey, data, req.Signature) { + return false, fmt.Errorf("cryptographic signature mismatch") + } + + return true, nil +} diff --git a/internal/remote/types/request.pb.go b/internal/remote/types/request.pb.go new file mode 100644 index 0000000..72f6d4d --- /dev/null +++ b/internal/remote/types/request.pb.go @@ -0,0 +1,1534 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.1 +// source: internal/remote/types/request.proto + +package types + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccountPk string `protobuf:"bytes,1,opt,name=account_pk,json=accountPk,proto3" json:"account_pk,omitempty"` + Signature []byte `protobuf:"bytes,2,opt,name=signature,proto3" json:"signature,omitempty"` + Timestamp int64 `protobuf:"varint,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + // Types that are valid to be assigned to Call: + // + // *Request_AccountGet + // *Request_AccountDelete + // *Request_MboxList + // *Request_MboxCreate + // *Request_MboxDelete + // *Request_MboxRename + // *Request_MboxSub + // *Request_MboxStatus + // *Request_MailPush + // *Request_MailFetch + // *Request_MailSearch + // *Request_MailFlags + // *Request_MailMove + // *Request_MailDel + // *Request_MailExpunge + // *Request_MailIdForSeq + // *Request_QueueInsert + // *Request_MailWatch + Call isRequest_Call `protobuf_oneof:"call"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Request) Reset() { + *x = Request{} + mi := &file_internal_remote_types_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{0} +} + +func (x *Request) GetAccountPk() string { + if x != nil { + return x.AccountPk + } + return "" +} + +func (x *Request) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *Request) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *Request) GetCall() isRequest_Call { + if x != nil { + return x.Call + } + return nil +} + +func (x *Request) GetAccountGet() *AccountGetRequest { + if x != nil { + if x, ok := x.Call.(*Request_AccountGet); ok { + return x.AccountGet + } + } + return nil +} + +func (x *Request) GetAccountDelete() *AccountDeleteRequest { + if x != nil { + if x, ok := x.Call.(*Request_AccountDelete); ok { + return x.AccountDelete + } + } + return nil +} + +func (x *Request) GetMboxList() *MailboxListRequest { + if x != nil { + if x, ok := x.Call.(*Request_MboxList); ok { + return x.MboxList + } + } + return nil +} + +func (x *Request) GetMboxCreate() *MailboxCreateRequest { + if x != nil { + if x, ok := x.Call.(*Request_MboxCreate); ok { + return x.MboxCreate + } + } + return nil +} + +func (x *Request) GetMboxDelete() *MailboxDeleteRequest { + if x != nil { + if x, ok := x.Call.(*Request_MboxDelete); ok { + return x.MboxDelete + } + } + return nil +} + +func (x *Request) GetMboxRename() *MailboxRenameRequest { + if x != nil { + if x, ok := x.Call.(*Request_MboxRename); ok { + return x.MboxRename + } + } + return nil +} + +func (x *Request) GetMboxSub() *MailboxSubscribeRequest { + if x != nil { + if x, ok := x.Call.(*Request_MboxSub); ok { + return x.MboxSub + } + } + return nil +} + +func (x *Request) GetMboxStatus() *MailboxStatusRequest { + if x != nil { + if x, ok := x.Call.(*Request_MboxStatus); ok { + return x.MboxStatus + } + } + return nil +} + +func (x *Request) GetMailPush() *MailPushRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailPush); ok { + return x.MailPush + } + } + return nil +} + +func (x *Request) GetMailFetch() *MailFetchRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailFetch); ok { + return x.MailFetch + } + } + return nil +} + +func (x *Request) GetMailSearch() *MailSearchRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailSearch); ok { + return x.MailSearch + } + } + return nil +} + +func (x *Request) GetMailFlags() *MailUpdateFlagsRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailFlags); ok { + return x.MailFlags + } + } + return nil +} + +func (x *Request) GetMailMove() *MailMoveRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailMove); ok { + return x.MailMove + } + } + return nil +} + +func (x *Request) GetMailDel() *MailDeleteRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailDel); ok { + return x.MailDel + } + } + return nil +} + +func (x *Request) GetMailExpunge() *MailExpungeRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailExpunge); ok { + return x.MailExpunge + } + } + return nil +} + +func (x *Request) GetMailIdForSeq() *MailIdForSeqRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailIdForSeq); ok { + return x.MailIdForSeq + } + } + return nil +} + +func (x *Request) GetQueueInsert() *QueueInsertRequest { + if x != nil { + if x, ok := x.Call.(*Request_QueueInsert); ok { + return x.QueueInsert + } + } + return nil +} + +func (x *Request) GetMailWatch() *WatchRequest { + if x != nil { + if x, ok := x.Call.(*Request_MailWatch); ok { + return x.MailWatch + } + } + return nil +} + +type isRequest_Call interface { + isRequest_Call() +} + +type Request_AccountGet struct { + // Core + AccountGet *AccountGetRequest `protobuf:"bytes,4,opt,name=account_get,json=accountGet,proto3,oneof"` +} + +type Request_AccountDelete struct { + AccountDelete *AccountDeleteRequest `protobuf:"bytes,5,opt,name=account_delete,json=accountDelete,proto3,oneof"` +} + +type Request_MboxList struct { + // Mailboxes + MboxList *MailboxListRequest `protobuf:"bytes,6,opt,name=mbox_list,json=mboxList,proto3,oneof"` +} + +type Request_MboxCreate struct { + MboxCreate *MailboxCreateRequest `protobuf:"bytes,7,opt,name=mbox_create,json=mboxCreate,proto3,oneof"` +} + +type Request_MboxDelete struct { + MboxDelete *MailboxDeleteRequest `protobuf:"bytes,8,opt,name=mbox_delete,json=mboxDelete,proto3,oneof"` +} + +type Request_MboxRename struct { + MboxRename *MailboxRenameRequest `protobuf:"bytes,9,opt,name=mbox_rename,json=mboxRename,proto3,oneof"` +} + +type Request_MboxSub struct { + MboxSub *MailboxSubscribeRequest `protobuf:"bytes,10,opt,name=mbox_sub,json=mboxSub,proto3,oneof"` +} + +type Request_MboxStatus struct { + MboxStatus *MailboxStatusRequest `protobuf:"bytes,11,opt,name=mbox_status,json=mboxStatus,proto3,oneof"` +} + +type Request_MailPush struct { + // Messages + MailPush *MailPushRequest `protobuf:"bytes,12,opt,name=mail_push,json=mailPush,proto3,oneof"` +} + +type Request_MailFetch struct { + MailFetch *MailFetchRequest `protobuf:"bytes,13,opt,name=mail_fetch,json=mailFetch,proto3,oneof"` +} + +type Request_MailSearch struct { + MailSearch *MailSearchRequest `protobuf:"bytes,14,opt,name=mail_search,json=mailSearch,proto3,oneof"` +} + +type Request_MailFlags struct { + MailFlags *MailUpdateFlagsRequest `protobuf:"bytes,15,opt,name=mail_flags,json=mailFlags,proto3,oneof"` +} + +type Request_MailMove struct { + MailMove *MailMoveRequest `protobuf:"bytes,16,opt,name=mail_move,json=mailMove,proto3,oneof"` +} + +type Request_MailDel struct { + MailDel *MailDeleteRequest `protobuf:"bytes,17,opt,name=mail_del,json=mailDel,proto3,oneof"` +} + +type Request_MailExpunge struct { + MailExpunge *MailExpungeRequest `protobuf:"bytes,18,opt,name=mail_expunge,json=mailExpunge,proto3,oneof"` +} + +type Request_MailIdForSeq struct { + MailIdForSeq *MailIdForSeqRequest `protobuf:"bytes,19,opt,name=mail_id_for_seq,json=mailIdForSeq,proto3,oneof"` +} + +type Request_QueueInsert struct { + // Queue + QueueInsert *QueueInsertRequest `protobuf:"bytes,21,opt,name=queue_insert,json=queueInsert,proto3,oneof"` +} + +type Request_MailWatch struct { + // Notify + MailWatch *WatchRequest `protobuf:"bytes,22,opt,name=mail_watch,json=mailWatch,proto3,oneof"` +} + +func (*Request_AccountGet) isRequest_Call() {} + +func (*Request_AccountDelete) isRequest_Call() {} + +func (*Request_MboxList) isRequest_Call() {} + +func (*Request_MboxCreate) isRequest_Call() {} + +func (*Request_MboxDelete) isRequest_Call() {} + +func (*Request_MboxRename) isRequest_Call() {} + +func (*Request_MboxSub) isRequest_Call() {} + +func (*Request_MboxStatus) isRequest_Call() {} + +func (*Request_MailPush) isRequest_Call() {} + +func (*Request_MailFetch) isRequest_Call() {} + +func (*Request_MailSearch) isRequest_Call() {} + +func (*Request_MailFlags) isRequest_Call() {} + +func (*Request_MailMove) isRequest_Call() {} + +func (*Request_MailDel) isRequest_Call() {} + +func (*Request_MailExpunge) isRequest_Call() {} + +func (*Request_MailIdForSeq) isRequest_Call() {} + +func (*Request_QueueInsert) isRequest_Call() {} + +func (*Request_MailWatch) isRequest_Call() {} + +type AccountGetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountGetRequest) Reset() { + *x = AccountGetRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountGetRequest) ProtoMessage() {} + +func (x *AccountGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountGetRequest.ProtoReflect.Descriptor instead. +func (*AccountGetRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{1} +} + +type AccountDeleteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountDeleteRequest) Reset() { + *x = AccountDeleteRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountDeleteRequest) ProtoMessage() {} + +func (x *AccountDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountDeleteRequest.ProtoReflect.Descriptor instead. +func (*AccountDeleteRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{2} +} + +type MailboxListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OnlySubscribed bool `protobuf:"varint,1,opt,name=only_subscribed,json=onlySubscribed,proto3" json:"only_subscribed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxListRequest) Reset() { + *x = MailboxListRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxListRequest) ProtoMessage() {} + +func (x *MailboxListRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxListRequest.ProtoReflect.Descriptor instead. +func (*MailboxListRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{3} +} + +func (x *MailboxListRequest) GetOnlySubscribed() bool { + if x != nil { + return x.OnlySubscribed + } + return false +} + +type MailboxCreateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxCreateRequest) Reset() { + *x = MailboxCreateRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxCreateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxCreateRequest) ProtoMessage() {} + +func (x *MailboxCreateRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxCreateRequest.ProtoReflect.Descriptor instead. +func (*MailboxCreateRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{4} +} + +func (x *MailboxCreateRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type MailboxDeleteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxDeleteRequest) Reset() { + *x = MailboxDeleteRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxDeleteRequest) ProtoMessage() {} + +func (x *MailboxDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxDeleteRequest.ProtoReflect.Descriptor instead. +func (*MailboxDeleteRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{5} +} + +func (x *MailboxDeleteRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type MailboxRenameRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OldName string `protobuf:"bytes,1,opt,name=old_name,json=oldName,proto3" json:"old_name,omitempty"` + NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxRenameRequest) Reset() { + *x = MailboxRenameRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxRenameRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxRenameRequest) ProtoMessage() {} + +func (x *MailboxRenameRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxRenameRequest.ProtoReflect.Descriptor instead. +func (*MailboxRenameRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{6} +} + +func (x *MailboxRenameRequest) GetOldName() string { + if x != nil { + return x.OldName + } + return "" +} + +func (x *MailboxRenameRequest) GetNewName() string { + if x != nil { + return x.NewName + } + return "" +} + +type MailboxSubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Subscribed bool `protobuf:"varint,2,opt,name=subscribed,proto3" json:"subscribed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxSubscribeRequest) Reset() { + *x = MailboxSubscribeRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxSubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxSubscribeRequest) ProtoMessage() {} + +func (x *MailboxSubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxSubscribeRequest.ProtoReflect.Descriptor instead. +func (*MailboxSubscribeRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{7} +} + +func (x *MailboxSubscribeRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *MailboxSubscribeRequest) GetSubscribed() bool { + if x != nil { + return x.Subscribed + } + return false +} + +type MailboxStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxStatusRequest) Reset() { + *x = MailboxStatusRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxStatusRequest) ProtoMessage() {} + +func (x *MailboxStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxStatusRequest.ProtoReflect.Descriptor instead. +func (*MailboxStatusRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{8} +} + +func (x *MailboxStatusRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type MailPushRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + From string `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"` + To string `protobuf:"bytes,2,opt,name=to,proto3" json:"to,omitempty"` + Mailbox string `protobuf:"bytes,4,opt,name=mailbox,proto3" json:"mailbox,omitempty"` // Поле content удалено: данные передаются через Payload.R + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailPushRequest) Reset() { + *x = MailPushRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailPushRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailPushRequest) ProtoMessage() {} + +func (x *MailPushRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailPushRequest.ProtoReflect.Descriptor instead. +func (*MailPushRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{9} +} + +func (x *MailPushRequest) GetFrom() string { + if x != nil { + return x.From + } + return "" +} + +func (x *MailPushRequest) GetTo() string { + if x != nil { + return x.To + } + return "" +} + +func (x *MailPushRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +type MailFetchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + Ids []uint32 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` + Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + Limit uint32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailFetchRequest) Reset() { + *x = MailFetchRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailFetchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailFetchRequest) ProtoMessage() {} + +func (x *MailFetchRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailFetchRequest.ProtoReflect.Descriptor instead. +func (*MailFetchRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{10} +} + +func (x *MailFetchRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +func (x *MailFetchRequest) GetIds() []uint32 { + if x != nil { + return x.Ids + } + return nil +} + +func (x *MailFetchRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *MailFetchRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +type MailSearchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + Criteria string `protobuf:"bytes,2,opt,name=criteria,proto3" json:"criteria,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailSearchRequest) Reset() { + *x = MailSearchRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailSearchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailSearchRequest) ProtoMessage() {} + +func (x *MailSearchRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailSearchRequest.ProtoReflect.Descriptor instead. +func (*MailSearchRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{11} +} + +func (x *MailSearchRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +func (x *MailSearchRequest) GetCriteria() string { + if x != nil { + return x.Criteria + } + return "" +} + +type MailUpdateFlagsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + Id uint32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + Seen bool `protobuf:"varint,3,opt,name=seen,proto3" json:"seen,omitempty"` + Answered bool `protobuf:"varint,4,opt,name=answered,proto3" json:"answered,omitempty"` + Flagged bool `protobuf:"varint,5,opt,name=flagged,proto3" json:"flagged,omitempty"` + Deleted bool `protobuf:"varint,6,opt,name=deleted,proto3" json:"deleted,omitempty"` + Op string `protobuf:"bytes,7,opt,name=op,proto3" json:"op,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailUpdateFlagsRequest) Reset() { + *x = MailUpdateFlagsRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailUpdateFlagsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailUpdateFlagsRequest) ProtoMessage() {} + +func (x *MailUpdateFlagsRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailUpdateFlagsRequest.ProtoReflect.Descriptor instead. +func (*MailUpdateFlagsRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{12} +} + +func (x *MailUpdateFlagsRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +func (x *MailUpdateFlagsRequest) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *MailUpdateFlagsRequest) GetSeen() bool { + if x != nil { + return x.Seen + } + return false +} + +func (x *MailUpdateFlagsRequest) GetAnswered() bool { + if x != nil { + return x.Answered + } + return false +} + +func (x *MailUpdateFlagsRequest) GetFlagged() bool { + if x != nil { + return x.Flagged + } + return false +} + +func (x *MailUpdateFlagsRequest) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +func (x *MailUpdateFlagsRequest) GetOp() string { + if x != nil { + return x.Op + } + return "" +} + +type MailMoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + Id uint32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + DestinationMailbox string `protobuf:"bytes,3,opt,name=destination_mailbox,json=destinationMailbox,proto3" json:"destination_mailbox,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailMoveRequest) Reset() { + *x = MailMoveRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailMoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailMoveRequest) ProtoMessage() {} + +func (x *MailMoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailMoveRequest.ProtoReflect.Descriptor instead. +func (*MailMoveRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{13} +} + +func (x *MailMoveRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +func (x *MailMoveRequest) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *MailMoveRequest) GetDestinationMailbox() string { + if x != nil { + return x.DestinationMailbox + } + return "" +} + +type MailDeleteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + Id uint32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailDeleteRequest) Reset() { + *x = MailDeleteRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailDeleteRequest) ProtoMessage() {} + +func (x *MailDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailDeleteRequest.ProtoReflect.Descriptor instead. +func (*MailDeleteRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{14} +} + +func (x *MailDeleteRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +func (x *MailDeleteRequest) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +type MailExpungeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailExpungeRequest) Reset() { + *x = MailExpungeRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailExpungeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailExpungeRequest) ProtoMessage() {} + +func (x *MailExpungeRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailExpungeRequest.ProtoReflect.Descriptor instead. +func (*MailExpungeRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{15} +} + +func (x *MailExpungeRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +type MailIdForSeqRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + Seq uint32 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailIdForSeqRequest) Reset() { + *x = MailIdForSeqRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailIdForSeqRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailIdForSeqRequest) ProtoMessage() {} + +func (x *MailIdForSeqRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailIdForSeqRequest.ProtoReflect.Descriptor instead. +func (*MailIdForSeqRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{16} +} + +func (x *MailIdForSeqRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +func (x *MailIdForSeqRequest) GetSeq() uint32 { + if x != nil { + return x.Seq + } + return 0 +} + +type QueueInsertRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Destination string `protobuf:"bytes,1,opt,name=destination,proto3" json:"destination,omitempty"` + Id uint32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + From string `protobuf:"bytes,3,opt,name=from,proto3" json:"from,omitempty"` + Rcpt string `protobuf:"bytes,4,opt,name=rcpt,proto3" json:"rcpt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueueInsertRequest) Reset() { + *x = QueueInsertRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueueInsertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueueInsertRequest) ProtoMessage() {} + +func (x *QueueInsertRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueueInsertRequest.ProtoReflect.Descriptor instead. +func (*QueueInsertRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{17} +} + +func (x *QueueInsertRequest) GetDestination() string { + if x != nil { + return x.Destination + } + return "" +} + +func (x *QueueInsertRequest) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *QueueInsertRequest) GetFrom() string { + if x != nil { + return x.From + } + return "" +} + +func (x *QueueInsertRequest) GetRcpt() string { + if x != nil { + return x.Rcpt + } + return "" +} + +type WatchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mailbox string `protobuf:"bytes,1,opt,name=mailbox,proto3" json:"mailbox,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchRequest) Reset() { + *x = WatchRequest{} + mi := &file_internal_remote_types_request_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchRequest) ProtoMessage() {} + +func (x *WatchRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_request_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchRequest.ProtoReflect.Descriptor instead. +func (*WatchRequest) Descriptor() ([]byte, []int) { + return file_internal_remote_types_request_proto_rawDescGZIP(), []int{18} +} + +func (x *WatchRequest) GetMailbox() string { + if x != nil { + return x.Mailbox + } + return "" +} + +var File_internal_remote_types_request_proto protoreflect.FileDescriptor + +const file_internal_remote_types_request_proto_rawDesc = "" + + "\n" + + "#internal/remote/types/request.proto\x12\x05types\"\xbd\t\n" + + "\aRequest\x12\x1d\n" + + "\n" + + "account_pk\x18\x01 \x01(\tR\taccountPk\x12\x1c\n" + + "\tsignature\x18\x02 \x01(\fR\tsignature\x12\x1c\n" + + "\ttimestamp\x18\x03 \x01(\x03R\ttimestamp\x12;\n" + + "\vaccount_get\x18\x04 \x01(\v2\x18.types.AccountGetRequestH\x00R\n" + + "accountGet\x12D\n" + + "\x0eaccount_delete\x18\x05 \x01(\v2\x1b.types.AccountDeleteRequestH\x00R\raccountDelete\x128\n" + + "\tmbox_list\x18\x06 \x01(\v2\x19.types.MailboxListRequestH\x00R\bmboxList\x12>\n" + + "\vmbox_create\x18\a \x01(\v2\x1b.types.MailboxCreateRequestH\x00R\n" + + "mboxCreate\x12>\n" + + "\vmbox_delete\x18\b \x01(\v2\x1b.types.MailboxDeleteRequestH\x00R\n" + + "mboxDelete\x12>\n" + + "\vmbox_rename\x18\t \x01(\v2\x1b.types.MailboxRenameRequestH\x00R\n" + + "mboxRename\x12;\n" + + "\bmbox_sub\x18\n" + + " \x01(\v2\x1e.types.MailboxSubscribeRequestH\x00R\amboxSub\x12>\n" + + "\vmbox_status\x18\v \x01(\v2\x1b.types.MailboxStatusRequestH\x00R\n" + + "mboxStatus\x125\n" + + "\tmail_push\x18\f \x01(\v2\x16.types.MailPushRequestH\x00R\bmailPush\x128\n" + + "\n" + + "mail_fetch\x18\r \x01(\v2\x17.types.MailFetchRequestH\x00R\tmailFetch\x12;\n" + + "\vmail_search\x18\x0e \x01(\v2\x18.types.MailSearchRequestH\x00R\n" + + "mailSearch\x12>\n" + + "\n" + + "mail_flags\x18\x0f \x01(\v2\x1d.types.MailUpdateFlagsRequestH\x00R\tmailFlags\x125\n" + + "\tmail_move\x18\x10 \x01(\v2\x16.types.MailMoveRequestH\x00R\bmailMove\x125\n" + + "\bmail_del\x18\x11 \x01(\v2\x18.types.MailDeleteRequestH\x00R\amailDel\x12>\n" + + "\fmail_expunge\x18\x12 \x01(\v2\x19.types.MailExpungeRequestH\x00R\vmailExpunge\x12C\n" + + "\x0fmail_id_for_seq\x18\x13 \x01(\v2\x1a.types.MailIdForSeqRequestH\x00R\fmailIdForSeq\x12>\n" + + "\fqueue_insert\x18\x15 \x01(\v2\x19.types.QueueInsertRequestH\x00R\vqueueInsert\x124\n" + + "\n" + + "mail_watch\x18\x16 \x01(\v2\x13.types.WatchRequestH\x00R\tmailWatchB\x06\n" + + "\x04call\"\x13\n" + + "\x11AccountGetRequest\"\x16\n" + + "\x14AccountDeleteRequest\"=\n" + + "\x12MailboxListRequest\x12'\n" + + "\x0fonly_subscribed\x18\x01 \x01(\bR\x0eonlySubscribed\"*\n" + + "\x14MailboxCreateRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"*\n" + + "\x14MailboxDeleteRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"L\n" + + "\x14MailboxRenameRequest\x12\x19\n" + + "\bold_name\x18\x01 \x01(\tR\aoldName\x12\x19\n" + + "\bnew_name\x18\x02 \x01(\tR\anewName\"M\n" + + "\x17MailboxSubscribeRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n" + + "\n" + + "subscribed\x18\x02 \x01(\bR\n" + + "subscribed\"*\n" + + "\x14MailboxStatusRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"O\n" + + "\x0fMailPushRequest\x12\x12\n" + + "\x04from\x18\x01 \x01(\tR\x04from\x12\x0e\n" + + "\x02to\x18\x02 \x01(\tR\x02to\x12\x18\n" + + "\amailbox\x18\x04 \x01(\tR\amailbox\"l\n" + + "\x10MailFetchRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\x12\x10\n" + + "\x03ids\x18\x02 \x03(\rR\x03ids\x12\x16\n" + + "\x06offset\x18\x03 \x01(\rR\x06offset\x12\x14\n" + + "\x05limit\x18\x04 \x01(\rR\x05limit\"I\n" + + "\x11MailSearchRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\x12\x1a\n" + + "\bcriteria\x18\x02 \x01(\tR\bcriteria\"\xb6\x01\n" + + "\x16MailUpdateFlagsRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\x12\x12\n" + + "\x04seen\x18\x03 \x01(\bR\x04seen\x12\x1a\n" + + "\banswered\x18\x04 \x01(\bR\banswered\x12\x18\n" + + "\aflagged\x18\x05 \x01(\bR\aflagged\x12\x18\n" + + "\adeleted\x18\x06 \x01(\bR\adeleted\x12\x0e\n" + + "\x02op\x18\a \x01(\tR\x02op\"l\n" + + "\x0fMailMoveRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\x12/\n" + + "\x13destination_mailbox\x18\x03 \x01(\tR\x12destinationMailbox\"=\n" + + "\x11MailDeleteRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\".\n" + + "\x12MailExpungeRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\"A\n" + + "\x13MailIdForSeqRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailbox\x12\x10\n" + + "\x03seq\x18\x02 \x01(\rR\x03seq\"n\n" + + "\x12QueueInsertRequest\x12 \n" + + "\vdestination\x18\x01 \x01(\tR\vdestination\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\x12\x12\n" + + "\x04from\x18\x03 \x01(\tR\x04from\x12\x12\n" + + "\x04rcpt\x18\x04 \x01(\tR\x04rcpt\"(\n" + + "\fWatchRequest\x12\x18\n" + + "\amailbox\x18\x01 \x01(\tR\amailboxB8Z6github.com/neilalexander/yggmail/internal/remote/typesb\x06proto3" + +var ( + file_internal_remote_types_request_proto_rawDescOnce sync.Once + file_internal_remote_types_request_proto_rawDescData []byte +) + +func file_internal_remote_types_request_proto_rawDescGZIP() []byte { + file_internal_remote_types_request_proto_rawDescOnce.Do(func() { + file_internal_remote_types_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_remote_types_request_proto_rawDesc), len(file_internal_remote_types_request_proto_rawDesc))) + }) + return file_internal_remote_types_request_proto_rawDescData +} + +var file_internal_remote_types_request_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_internal_remote_types_request_proto_goTypes = []any{ + (*Request)(nil), // 0: types.Request + (*AccountGetRequest)(nil), // 1: types.AccountGetRequest + (*AccountDeleteRequest)(nil), // 2: types.AccountDeleteRequest + (*MailboxListRequest)(nil), // 3: types.MailboxListRequest + (*MailboxCreateRequest)(nil), // 4: types.MailboxCreateRequest + (*MailboxDeleteRequest)(nil), // 5: types.MailboxDeleteRequest + (*MailboxRenameRequest)(nil), // 6: types.MailboxRenameRequest + (*MailboxSubscribeRequest)(nil), // 7: types.MailboxSubscribeRequest + (*MailboxStatusRequest)(nil), // 8: types.MailboxStatusRequest + (*MailPushRequest)(nil), // 9: types.MailPushRequest + (*MailFetchRequest)(nil), // 10: types.MailFetchRequest + (*MailSearchRequest)(nil), // 11: types.MailSearchRequest + (*MailUpdateFlagsRequest)(nil), // 12: types.MailUpdateFlagsRequest + (*MailMoveRequest)(nil), // 13: types.MailMoveRequest + (*MailDeleteRequest)(nil), // 14: types.MailDeleteRequest + (*MailExpungeRequest)(nil), // 15: types.MailExpungeRequest + (*MailIdForSeqRequest)(nil), // 16: types.MailIdForSeqRequest + (*QueueInsertRequest)(nil), // 17: types.QueueInsertRequest + (*WatchRequest)(nil), // 18: types.WatchRequest +} +var file_internal_remote_types_request_proto_depIdxs = []int32{ + 1, // 0: types.Request.account_get:type_name -> types.AccountGetRequest + 2, // 1: types.Request.account_delete:type_name -> types.AccountDeleteRequest + 3, // 2: types.Request.mbox_list:type_name -> types.MailboxListRequest + 4, // 3: types.Request.mbox_create:type_name -> types.MailboxCreateRequest + 5, // 4: types.Request.mbox_delete:type_name -> types.MailboxDeleteRequest + 6, // 5: types.Request.mbox_rename:type_name -> types.MailboxRenameRequest + 7, // 6: types.Request.mbox_sub:type_name -> types.MailboxSubscribeRequest + 8, // 7: types.Request.mbox_status:type_name -> types.MailboxStatusRequest + 9, // 8: types.Request.mail_push:type_name -> types.MailPushRequest + 10, // 9: types.Request.mail_fetch:type_name -> types.MailFetchRequest + 11, // 10: types.Request.mail_search:type_name -> types.MailSearchRequest + 12, // 11: types.Request.mail_flags:type_name -> types.MailUpdateFlagsRequest + 13, // 12: types.Request.mail_move:type_name -> types.MailMoveRequest + 14, // 13: types.Request.mail_del:type_name -> types.MailDeleteRequest + 15, // 14: types.Request.mail_expunge:type_name -> types.MailExpungeRequest + 16, // 15: types.Request.mail_id_for_seq:type_name -> types.MailIdForSeqRequest + 17, // 16: types.Request.queue_insert:type_name -> types.QueueInsertRequest + 18, // 17: types.Request.mail_watch:type_name -> types.WatchRequest + 18, // [18:18] is the sub-list for method output_type + 18, // [18:18] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name +} + +func init() { file_internal_remote_types_request_proto_init() } +func file_internal_remote_types_request_proto_init() { + if File_internal_remote_types_request_proto != nil { + return + } + file_internal_remote_types_request_proto_msgTypes[0].OneofWrappers = []any{ + (*Request_AccountGet)(nil), + (*Request_AccountDelete)(nil), + (*Request_MboxList)(nil), + (*Request_MboxCreate)(nil), + (*Request_MboxDelete)(nil), + (*Request_MboxRename)(nil), + (*Request_MboxSub)(nil), + (*Request_MboxStatus)(nil), + (*Request_MailPush)(nil), + (*Request_MailFetch)(nil), + (*Request_MailSearch)(nil), + (*Request_MailFlags)(nil), + (*Request_MailMove)(nil), + (*Request_MailDel)(nil), + (*Request_MailExpunge)(nil), + (*Request_MailIdForSeq)(nil), + (*Request_QueueInsert)(nil), + (*Request_MailWatch)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_remote_types_request_proto_rawDesc), len(file_internal_remote_types_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 19, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_internal_remote_types_request_proto_goTypes, + DependencyIndexes: file_internal_remote_types_request_proto_depIdxs, + MessageInfos: file_internal_remote_types_request_proto_msgTypes, + }.Build() + File_internal_remote_types_request_proto = out.File + file_internal_remote_types_request_proto_goTypes = nil + file_internal_remote_types_request_proto_depIdxs = nil +} diff --git a/internal/remote/types/request.proto b/internal/remote/types/request.proto new file mode 100644 index 0000000..5f2f3f5 --- /dev/null +++ b/internal/remote/types/request.proto @@ -0,0 +1,130 @@ +syntax = "proto3"; + +package types; + +option go_package = "github.com/neilalexander/yggmail/internal/remote/types"; + +message Request { + uint32 version = 1 + string account_pk = 2; + bytes signature = 3; + int64 timestamp = 4; + + oneof call { + // Core + AccountGetRequest account_get = 5; + AccountDeleteRequest account_delete = 6; + + // Mailboxes + MailboxListRequest mbox_list = 7; + MailboxCreateRequest mbox_create = 8; + MailboxDeleteRequest mbox_delete = 9; + MailboxRenameRequest mbox_rename = 10; + MailboxSubscribeRequest mbox_sub = 11; + MailboxStatusRequest mbox_status = 12; + + // Messages + MailPushRequest mail_push = 13; + MailFetchRequest mail_fetch = 14; + MailSearchRequest mail_search = 15; + MailUpdateFlagsRequest mail_flags = 16; + MailMoveRequest mail_move = 17; + MailDeleteRequest mail_del = 18; + MailExpungeRequest mail_expunge = 19; + MailIdForSeqRequest mail_id_for_seq = 20; + + // Queue + QueueInsertRequest queue_insert = 22; + + // Notify + WatchRequest mail_watch = 23; + } +} + +message AccountGetRequest {} +message AccountDeleteRequest {} + +message MailboxListRequest { + bool only_subscribed = 1; +} + +message MailboxCreateRequest { + string name = 1; +} + +message MailboxDeleteRequest { + string name = 1; +} + +message MailboxRenameRequest { + string old_name = 1; + string new_name = 2; +} + +message MailboxSubscribeRequest { + string name = 1; + bool subscribed = 2; +} + +message MailboxStatusRequest { + string name = 1; +} + +message MailPushRequest { + string from = 1; + string to = 2; + string mailbox = 4; +} + +message MailFetchRequest { + string mailbox = 1; + repeated uint32 ids = 2; + uint32 offset = 3; + uint32 limit = 4; +} + +message MailSearchRequest { + string mailbox = 1; + string criteria = 2; +} + +message MailUpdateFlagsRequest { + string mailbox = 1; + uint32 id = 2; + bool seen = 3; + bool answered = 4; + bool flagged = 5; + bool deleted = 6; + string op = 7; +} + +message MailMoveRequest { + string mailbox = 1; + uint32 id = 2; + string destination_mailbox = 3; +} + +message MailDeleteRequest { + string mailbox = 1; + uint32 id = 2; +} + +message MailExpungeRequest { + string mailbox = 1; +} + +message MailIdForSeqRequest { + string mailbox = 1; + uint32 seq = 2; +} + +message QueueInsertRequest { + string destination = 1; + uint32 id = 2; + string from = 3; + string rcpt = 4; +} + +message WatchRequest { + string mailbox = 1; +} diff --git a/internal/remote/types/response.pb.go b/internal/remote/types/response.pb.go new file mode 100644 index 0000000..5edf2a2 --- /dev/null +++ b/internal/remote/types/response.pb.go @@ -0,0 +1,606 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.1 +// source: internal/remote/types/response.proto + +package types + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Response struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + // Types that are valid to be assigned to Data: + // + // *Response_Account + // *Response_MboxList + // *Response_MboxStatus + // *Response_MailList + // *Response_IdResult + // *Response_BoolResult + Data isResponse_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Response) Reset() { + *x = Response{} + mi := &file_internal_remote_types_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Response) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Response) ProtoMessage() {} + +func (x *Response) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_response_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Response.ProtoReflect.Descriptor instead. +func (*Response) Descriptor() ([]byte, []int) { + return file_internal_remote_types_response_proto_rawDescGZIP(), []int{0} +} + +func (x *Response) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *Response) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *Response) GetData() isResponse_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *Response) GetAccount() *AccountInfo { + if x != nil { + if x, ok := x.Data.(*Response_Account); ok { + return x.Account + } + } + return nil +} + +func (x *Response) GetMboxList() *MailboxList { + if x != nil { + if x, ok := x.Data.(*Response_MboxList); ok { + return x.MboxList + } + } + return nil +} + +func (x *Response) GetMboxStatus() *MailboxStatus { + if x != nil { + if x, ok := x.Data.(*Response_MboxStatus); ok { + return x.MboxStatus + } + } + return nil +} + +func (x *Response) GetMailList() *MailList { + if x != nil { + if x, ok := x.Data.(*Response_MailList); ok { + return x.MailList + } + } + return nil +} + +func (x *Response) GetIdResult() uint32 { + if x != nil { + if x, ok := x.Data.(*Response_IdResult); ok { + return x.IdResult + } + } + return 0 +} + +func (x *Response) GetBoolResult() bool { + if x != nil { + if x, ok := x.Data.(*Response_BoolResult); ok { + return x.BoolResult + } + } + return false +} + +type isResponse_Data interface { + isResponse_Data() +} + +type Response_Account struct { + Account *AccountInfo `protobuf:"bytes,3,opt,name=account,proto3,oneof"` +} + +type Response_MboxList struct { + MboxList *MailboxList `protobuf:"bytes,4,opt,name=mbox_list,json=mboxList,proto3,oneof"` +} + +type Response_MboxStatus struct { + MboxStatus *MailboxStatus `protobuf:"bytes,5,opt,name=mbox_status,json=mboxStatus,proto3,oneof"` +} + +type Response_MailList struct { + MailList *MailList `protobuf:"bytes,6,opt,name=mail_list,json=mailList,proto3,oneof"` +} + +type Response_IdResult struct { + IdResult uint32 `protobuf:"varint,7,opt,name=id_result,json=idResult,proto3,oneof"` +} + +type Response_BoolResult struct { + BoolResult bool `protobuf:"varint,8,opt,name=bool_result,json=boolResult,proto3,oneof"` +} + +func (*Response_Account) isResponse_Data() {} + +func (*Response_MboxList) isResponse_Data() {} + +func (*Response_MboxStatus) isResponse_Data() {} + +func (*Response_MailList) isResponse_Data() {} + +func (*Response_IdResult) isResponse_Data() {} + +func (*Response_BoolResult) isResponse_Data() {} + +type AccountInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pubkey string `protobuf:"bytes,1,opt,name=pubkey,proto3" json:"pubkey,omitempty"` + Aliases string `protobuf:"bytes,2,opt,name=aliases,proto3" json:"aliases,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountInfo) Reset() { + *x = AccountInfo{} + mi := &file_internal_remote_types_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountInfo) ProtoMessage() {} + +func (x *AccountInfo) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_response_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountInfo.ProtoReflect.Descriptor instead. +func (*AccountInfo) Descriptor() ([]byte, []int) { + return file_internal_remote_types_response_proto_rawDescGZIP(), []int{1} +} + +func (x *AccountInfo) GetPubkey() string { + if x != nil { + return x.Pubkey + } + return "" +} + +func (x *AccountInfo) GetAliases() string { + if x != nil { + return x.Aliases + } + return "" +} + +type MailboxList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxList) Reset() { + *x = MailboxList{} + mi := &file_internal_remote_types_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxList) ProtoMessage() {} + +func (x *MailboxList) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_response_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxList.ProtoReflect.Descriptor instead. +func (*MailboxList) Descriptor() ([]byte, []int) { + return file_internal_remote_types_response_proto_rawDescGZIP(), []int{2} +} + +func (x *MailboxList) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +type MailboxStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + MessagesCount uint32 `protobuf:"varint,2,opt,name=messages_count,json=messagesCount,proto3" json:"messages_count,omitempty"` + UnseenCount uint32 `protobuf:"varint,3,opt,name=unseen_count,json=unseenCount,proto3" json:"unseen_count,omitempty"` + UidNext uint32 `protobuf:"varint,4,opt,name=uid_next,json=uidNext,proto3" json:"uid_next,omitempty"` + UidValidity uint32 `protobuf:"varint,5,opt,name=uid_validity,json=uidValidity,proto3" json:"uid_validity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailboxStatus) Reset() { + *x = MailboxStatus{} + mi := &file_internal_remote_types_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailboxStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailboxStatus) ProtoMessage() {} + +func (x *MailboxStatus) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_response_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailboxStatus.ProtoReflect.Descriptor instead. +func (*MailboxStatus) Descriptor() ([]byte, []int) { + return file_internal_remote_types_response_proto_rawDescGZIP(), []int{3} +} + +func (x *MailboxStatus) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *MailboxStatus) GetMessagesCount() uint32 { + if x != nil { + return x.MessagesCount + } + return 0 +} + +func (x *MailboxStatus) GetUnseenCount() uint32 { + if x != nil { + return x.UnseenCount + } + return 0 +} + +func (x *MailboxStatus) GetUidNext() uint32 { + if x != nil { + return x.UidNext + } + return 0 +} + +func (x *MailboxStatus) GetUidValidity() uint32 { + if x != nil { + return x.UidValidity + } + return 0 +} + +type MailMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Seq uint32 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` + Date int64 `protobuf:"varint,4,opt,name=date,proto3" json:"date,omitempty"` + Seen bool `protobuf:"varint,5,opt,name=seen,proto3" json:"seen,omitempty"` + Answered bool `protobuf:"varint,6,opt,name=answered,proto3" json:"answered,omitempty"` + Flagged bool `protobuf:"varint,7,opt,name=flagged,proto3" json:"flagged,omitempty"` + Deleted bool `protobuf:"varint,8,opt,name=deleted,proto3" json:"deleted,omitempty"` + Size uint32 `protobuf:"varint,9,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailMessage) Reset() { + *x = MailMessage{} + mi := &file_internal_remote_types_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailMessage) ProtoMessage() {} + +func (x *MailMessage) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_response_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailMessage.ProtoReflect.Descriptor instead. +func (*MailMessage) Descriptor() ([]byte, []int) { + return file_internal_remote_types_response_proto_rawDescGZIP(), []int{4} +} + +func (x *MailMessage) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *MailMessage) GetSeq() uint32 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *MailMessage) GetDate() int64 { + if x != nil { + return x.Date + } + return 0 +} + +func (x *MailMessage) GetSeen() bool { + if x != nil { + return x.Seen + } + return false +} + +func (x *MailMessage) GetAnswered() bool { + if x != nil { + return x.Answered + } + return false +} + +func (x *MailMessage) GetFlagged() bool { + if x != nil { + return x.Flagged + } + return false +} + +func (x *MailMessage) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +func (x *MailMessage) GetSize() uint32 { + if x != nil { + return x.Size + } + return 0 +} + +type MailList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*MailMessage `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailList) Reset() { + *x = MailList{} + mi := &file_internal_remote_types_response_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailList) ProtoMessage() {} + +func (x *MailList) ProtoReflect() protoreflect.Message { + mi := &file_internal_remote_types_response_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailList.ProtoReflect.Descriptor instead. +func (*MailList) Descriptor() ([]byte, []int) { + return file_internal_remote_types_response_proto_rawDescGZIP(), []int{5} +} + +func (x *MailList) GetItems() []*MailMessage { + if x != nil { + return x.Items + } + return nil +} + +var File_internal_remote_types_response_proto protoreflect.FileDescriptor + +const file_internal_remote_types_response_proto_rawDesc = "" + + "\n" + + "$internal/remote/types/response.proto\x12\x05types\"\xd0\x02\n" + + "\bResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12.\n" + + "\aaccount\x18\x03 \x01(\v2\x12.types.AccountInfoH\x00R\aaccount\x121\n" + + "\tmbox_list\x18\x04 \x01(\v2\x12.types.MailboxListH\x00R\bmboxList\x127\n" + + "\vmbox_status\x18\x05 \x01(\v2\x14.types.MailboxStatusH\x00R\n" + + "mboxStatus\x12.\n" + + "\tmail_list\x18\x06 \x01(\v2\x0f.types.MailListH\x00R\bmailList\x12\x1d\n" + + "\tid_result\x18\a \x01(\rH\x00R\bidResult\x12!\n" + + "\vbool_result\x18\b \x01(\bH\x00R\n" + + "boolResultB\x06\n" + + "\x04data\"?\n" + + "\vAccountInfo\x12\x16\n" + + "\x06pubkey\x18\x01 \x01(\tR\x06pubkey\x12\x18\n" + + "\aaliases\x18\x02 \x01(\tR\aaliases\"#\n" + + "\vMailboxList\x12\x14\n" + + "\x05names\x18\x01 \x03(\tR\x05names\"\xab\x01\n" + + "\rMailboxStatus\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12%\n" + + "\x0emessages_count\x18\x02 \x01(\rR\rmessagesCount\x12!\n" + + "\funseen_count\x18\x03 \x01(\rR\vunseenCount\x12\x19\n" + + "\buid_next\x18\x04 \x01(\rR\auidNext\x12!\n" + + "\fuid_validity\x18\x05 \x01(\rR\vuidValidity\"\xbb\x01\n" + + "\vMailMessage\x12\x0e\n" + + "\x02id\x18\x01 \x01(\rR\x02id\x12\x10\n" + + "\x03seq\x18\x02 \x01(\rR\x03seq\x12\x12\n" + + "\x04date\x18\x04 \x01(\x03R\x04date\x12\x12\n" + + "\x04seen\x18\x05 \x01(\bR\x04seen\x12\x1a\n" + + "\banswered\x18\x06 \x01(\bR\banswered\x12\x18\n" + + "\aflagged\x18\a \x01(\bR\aflagged\x12\x18\n" + + "\adeleted\x18\b \x01(\bR\adeleted\x12\x12\n" + + "\x04size\x18\t \x01(\rR\x04size\"4\n" + + "\bMailList\x12(\n" + + "\x05items\x18\x01 \x03(\v2\x12.types.MailMessageR\x05itemsB8Z6github.com/neilalexander/yggmail/internal/remote/typesb\x06proto3" + +var ( + file_internal_remote_types_response_proto_rawDescOnce sync.Once + file_internal_remote_types_response_proto_rawDescData []byte +) + +func file_internal_remote_types_response_proto_rawDescGZIP() []byte { + file_internal_remote_types_response_proto_rawDescOnce.Do(func() { + file_internal_remote_types_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_remote_types_response_proto_rawDesc), len(file_internal_remote_types_response_proto_rawDesc))) + }) + return file_internal_remote_types_response_proto_rawDescData +} + +var file_internal_remote_types_response_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_internal_remote_types_response_proto_goTypes = []any{ + (*Response)(nil), // 0: types.Response + (*AccountInfo)(nil), // 1: types.AccountInfo + (*MailboxList)(nil), // 2: types.MailboxList + (*MailboxStatus)(nil), // 3: types.MailboxStatus + (*MailMessage)(nil), // 4: types.MailMessage + (*MailList)(nil), // 5: types.MailList +} +var file_internal_remote_types_response_proto_depIdxs = []int32{ + 1, // 0: types.Response.account:type_name -> types.AccountInfo + 2, // 1: types.Response.mbox_list:type_name -> types.MailboxList + 3, // 2: types.Response.mbox_status:type_name -> types.MailboxStatus + 5, // 3: types.Response.mail_list:type_name -> types.MailList + 4, // 4: types.MailList.items:type_name -> types.MailMessage + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_internal_remote_types_response_proto_init() } +func file_internal_remote_types_response_proto_init() { + if File_internal_remote_types_response_proto != nil { + return + } + file_internal_remote_types_response_proto_msgTypes[0].OneofWrappers = []any{ + (*Response_Account)(nil), + (*Response_MboxList)(nil), + (*Response_MboxStatus)(nil), + (*Response_MailList)(nil), + (*Response_IdResult)(nil), + (*Response_BoolResult)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_remote_types_response_proto_rawDesc), len(file_internal_remote_types_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_internal_remote_types_response_proto_goTypes, + DependencyIndexes: file_internal_remote_types_response_proto_depIdxs, + MessageInfos: file_internal_remote_types_response_proto_msgTypes, + }.Build() + File_internal_remote_types_response_proto = out.File + file_internal_remote_types_response_proto_goTypes = nil + file_internal_remote_types_response_proto_depIdxs = nil +} diff --git a/internal/remote/types/response.proto b/internal/remote/types/response.proto new file mode 100644 index 0000000..5c344c6 --- /dev/null +++ b/internal/remote/types/response.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package types; + +option go_package = "github.com/neilalexander/yggmail/internal/remote/types"; + +message Response { + bool success = 1; + string error = 2; + + oneof data { + AccountInfo account = 3; + MailboxList mbox_list = 4; + MailboxStatus mbox_status = 5; + MailList mail_list = 6; + uint32 id_result = 7; + bool bool_result = 8; + } +} + +message AccountInfo { + string pubkey = 1; + string aliases = 2; +} + +message MailboxList { + repeated string names = 1; +} + +message MailboxStatus { + string name = 1; + uint32 messages_count = 2; + uint32 unseen_count = 3; + uint32 uid_next = 4; + uint32 uid_validity = 5; +} + +message MailMessage { + uint32 id = 1; + uint32 seq = 2; + int64 date = 4; + bool seen = 5; + bool answered = 6; + bool flagged = 7; + bool deleted = 8; + uint32 size = 9; +} + +message MailList { + repeated MailMessage items = 1; +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 0000000..b00062e --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package shell + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "os" + + "github.com/neilalexander/yggmail/internal/account" + "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/types" + "github.com/neilalexander/yggmail/internal/utils" +) + +type Shell struct { + storage storage.Storage + account *account.Manager + cfg *config.Config +} + +func NewShell(storage storage.Storage, log *log.Logger, cfg *config.Config) *Shell { + return &Shell{ + storage: storage, + account: account.NewManager(storage, log), + cfg: cfg, + } +} + +func passwordProvider() (string, error) { + p, err := account.DefaultProvider("Enter password for your new mail account: ") + if err != nil { + return "", err + } + p2, err := account.DefaultProvider("Comfirm your password: ") + if err != nil { + return "", err + } + if p == p2 { + return p2, nil + } + return "", fmt.Errorf("Password not match") +} + +type shellCommand func(args []string) error + +func (s *Shell) Run(c string, args []string) { + // fmt.Println(c, args) + var err error + + commands := map[string]shellCommand{ + "accountAdd": s.accountAdd, + "accountCreateNew": s.accountCreateNew, + "accountGet": s.accountGet, + "accountList": s.accountList, + "accountDelete": s.accountDelete, + "nodeInfo": s.getNodeInfo, + } + cmd, ok := commands[c] + if !ok { + log.Fatal("Command not found!") + os.Exit(1) + } + err = cmd(args) + if err != nil { + fmt.Println(err) + } +} + +func (s *Shell) accountAdd(args []string) error { + + flags := flag.NewFlagSet("addAccount", flag.ExitOnError) + // All flags + pubkey := flags.String("pub", "", "Account public key") + file := flags.String("f", "", "Added from file") + + flags.Parse(args) + + var ( + err error + acc *types.Account = new(types.Account) + ) + if *file != "" { + cont, err := os.ReadFile(*file) + if err != nil { + return err + } + if err := json.Unmarshal(cont, acc); err != nil { + return err + } + if err := s.storage.AccountCreate(acc); err != nil { + return err + } + fmt.Printf("Account %s@%s added", acc.PublicKey, utils.EncodeString(s.cfg.PublicKey)) + for _, folder := range []string{"INBOX", "Outbox", "Sent", "Trash", "Drafts"} { + s.storage.MailboxCreate(acc.PublicKey, folder) + } + return nil + } + + if acc, err = s.account.AddAccount(*pubkey); err != nil { + return err + } + fmt.Printf("Account %s@%s added", acc.PublicKey, utils.EncodeString(s.cfg.PublicKey)) + return nil +} + +func (s *Shell) getNodeInfo(args []string) error { + + flags := flag.NewFlagSet("nodeInfo", flag.ExitOnError) + flags.Parse(args) + + pks := utils.EncodeString(s.cfg.PublicKey) + accList, err := s.storage.AccountList() + if err != nil { + return err + } + + fmt.Println("== Node Info == ") + fmt.Println("Address base32:", pks) + fmt.Println("Address hex16:", hex.EncodeToString(s.cfg.PublicKey)) + fmt.Println("Accounts: ", len(accList)) + return nil +} + +func (s *Shell) accountList(args []string) error { + accList, err := s.storage.AccountList() + if err != nil { + return err + } + fmt.Println("Accounts: ", len(accList)) + for i, a := range accList { + count, err := s.storage.MailCount(a.PublicKey, "INBOX") + if err != nil { + count = 0 + } + fmt.Printf("\n| %d | %s | %s | Mails: %d", i, a.PublicKey, a.Aliases, count) + } + return nil +} + +func (s *Shell) accountCreateNew(args []string) error { + a, err := s.account.CreateNewAccount(passwordProvider) + if err != nil { + return err + } + fmt.Printf("Created account: %s@%s", a.PublicKey, utils.EncodeString(s.cfg.PublicKey)) + return nil +} + +func (s *Shell) accountGet(args []string) error { + flags := flag.NewFlagSet("getAccount", flag.ExitOnError) + // All flags + pubkey := flags.String("pub", "", "Account Publickey") + file := flags.String("o", "", "Write to file") + + flags.Parse(args) + + var ( + err error + acc *types.Account + ) + + if acc, err = s.account.GetAccount(*pubkey); err != nil { + return err + } + b, err := json.Marshal(acc) + if err != nil { + return err + } + if *file == "" { + fmt.Println(string(b)) + } else { + os.WriteFile(*file, b, 0600) + fmt.Println("Write to ", *file) + } + return nil +} + +func (s *Shell) accountDelete(args []string) error { + flags := flag.NewFlagSet("getAccount", flag.ExitOnError) + // All flags + pubkey := flags.String("pub", "", "Account Publickey") + flags.Parse(args) + + var ( + err error + ) + if err = s.account.DeleteAccount(*pubkey); err != nil { + return err + } + return nil + +} diff --git a/internal/smtpsender/sender.go b/internal/smtpsender/sender.go index e9cf573..7dec02a 100644 --- a/internal/smtpsender/sender.go +++ b/internal/smtpsender/sender.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Neil Alexander + * Original Copyright (c) 2021 Neil Alexander) * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -9,18 +9,23 @@ package smtpsender import ( + "crypto/ed25519" "database/sql" "encoding/hex" "errors" "fmt" + "io" "log" "net/mail" "sync" "time" - "github.com/emersion/go-smtp" "github.com/neilalexander/yggmail/internal/config" + "github.com/neilalexander/yggmail/internal/notify" + "github.com/neilalexander/yggmail/internal/remote" + pb "github.com/neilalexander/yggmail/internal/remote/types" "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/types" "github.com/neilalexander/yggmail/internal/transport" "github.com/neilalexander/yggmail/internal/utils" "go.uber.org/atomic" @@ -31,15 +36,19 @@ type Queues struct { Log *log.Logger Transport transport.Transport Storage storage.Storage - queues sync.Map // servername -> *Queue + RPCClient *remote.Client + Notify *notify.Notify + queues sync.Map } -func NewQueues(config *config.Config, log *log.Logger, transport transport.Transport, storage storage.Storage) *Queues { +func NewQueues(config *config.Config, log *log.Logger, transport transport.Transport, storage storage.Storage, rpcClient *remote.Client, notify *notify.Notify) *Queues { qs := &Queues{ Config: config, Log: log, Transport: transport, Storage: storage, + RPCClient: rpcClient, + Notify: notify, } time.AfterFunc(time.Second*5, qs.manager) return qs @@ -51,142 +60,164 @@ func (qs *Queues) manager() { return } for _, destination := range destinations { - _, _ = qs.queueFor(destination) + _ = qs.QueueForServer(destination) } time.AfterFunc(time.Minute, qs.manager) } -func (qs *Queues) QueueFor(from string, rcpts []string, content []byte) error { - pid, err := qs.Storage.MailCreate("Outbox", content) - +func (qs *Queues) QueueFor(storage storage.Storage, senderPK string, from string, rcpts []string, m io.Reader, size int) error { + pid, err := storage.MailCreate(senderPK, "Outbox", m, size) if err != nil { - return fmt.Errorf("q.queues.Storage.MailCreate: %w", err) + return fmt.Errorf("storage.MailCreate(Outbox): %w", err) } for _, rcpt := range rcpts { addr, err := mail.ParseAddress(rcpt) if err != nil { - return fmt.Errorf("mail.ParseAddress: %w", err) - } - pk, err := utils.ParseAddress(addr.Address) - if err != nil { - return fmt.Errorf("parseAddress: %w", err) - } - host := hex.EncodeToString(pk) - if host == hex.EncodeToString(qs.Config.PublicKey) { continue } - if err := qs.Storage.QueueInsertDestinationForID(host, pid, from, rcpt); err != nil { - return fmt.Errorf("qs.Storage.QueueInsertDestinationForID: %w", err) + dPk, _, err := utils.ParseAddress(addr.Address) + if err != nil { + continue } - _, _ = qs.queueFor(host) + hostHex := hex.EncodeToString(dPk) + err = storage.QueueInsertDestinationForID(senderPK, hostHex, pid, from, rcpt) + if err != nil { + qs.Log.Printf("Failed to insert queue for %s: %v", hostHex, err) + continue + } + if storage == qs.Storage { + _ = qs.QueueForServer(hostHex) + } } return nil } -func (qs *Queues) queueFor(server string) (*Queue, error) { +func (qs *Queues) QueueForServer(server string) error { v, _ := qs.queues.LoadOrStore(server, &Queue{ queues: qs, destination: server, }) q, ok := v.(*Queue) if !ok { - return nil, fmt.Errorf("type assertion error") + return fmt.Errorf("type assertion error") } if q.running.CompareAndSwap(false, true) { go q.run() } - return q, nil + return nil } type Queue struct { - queues *Queues + queues *Queues + // hex16 destination string running atomic.Bool } func (q *Queue) run() { defer q.running.Store(false) - - refs, err := q.queues.Storage.QueueMailIDsForDestination(q.destination) - if err != nil { - q.queues.Log.Println("Error with queue:", err) - } - defer q.queues.Storage.MailExpunge("Outbox") // nolint:errcheck - - for _, ref := range refs { - _, mail, err := q.queues.Storage.MailSelect("Outbox", ref.ID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - if err = q.queues.Storage.QueueDeleteDestinationForID("Outbox", ref.ID); err != nil { - q.queues.Log.Println("Failed delete queue destination for ID", ref.ID, "due to error:", err) - } - } else { - q.queues.Log.Println("Failed to get mail", ref.ID, "due to error:", err) - } - continue + for { + refs, err := q.queues.Storage.QueueMailIDsForDestination(q.destination) + if err != nil || len(refs) == 0 { + return } - q.queues.Log.Println("Sending mail from", ref.From, "to", q.destination) - - if err := func() error { - conn, err := q.queues.Transport.Dial(q.destination) + for _, ref := range refs { + _, mail, err := q.queues.Storage.MailSelect(ref.AccountPubKey, "Outbox", ref.ID) if err != nil { - return fmt.Errorf("q.queues.Transport.Dial: %w", err) - } - defer conn.Close() // nolint:errcheck - - client, err := smtp.NewClient(conn, q.destination) - if err != nil { - return fmt.Errorf("smtp.NewClient: %w", err) - } - defer client.Close() // nolint:errcheck - - if err := client.Hello(hex.EncodeToString(q.queues.Config.PublicKey)); err != nil { - q.queues.Log.Println("Remote server", q.destination, "did not accept HELLO:", err) - return fmt.Errorf("client.Hello: %w", err) + q.handleError(ref, err) + continue } - if err := client.Mail(ref.From, nil); err != nil { - q.queues.Log.Println("Remote server", q.destination, "did not accept MAIL:", err) - return fmt.Errorf("client.Mail: %w", err) + q.queues.Log.Printf("Sending mail from %s (Acc: %s) to %s", ref.From, ref.AccountPubKey, q.destination) + + deliver := q.deliverRemote + if q.destination == hex.EncodeToString(q.queues.Config.PublicKey) { + deliver = q.deliverLocal } - if err := client.Rcpt(ref.Rcpt); err != nil { - q.queues.Log.Println("Remote server", q.destination, "did not accept RCPT:", err) - return fmt.Errorf("client.Rcpt: %w", err) + if err := deliver(ref, mail.Body, mail.Size); err != nil { + q.queues.Log.Printf("Delivery to %s failed: %v. Will retry later.", q.destination, err) + return } - writer, err := client.Data() - if err != nil { - return fmt.Errorf("client.Data: %w", err) - } - defer writer.Close() // nolint:errcheck - - if _, err := writer.Write(mail.Mail); err != nil { - return fmt.Errorf("writer.Write: %w", err) - } - - if err := q.queues.Storage.QueueDeleteDestinationForID(q.destination, ref.ID); err != nil { - return fmt.Errorf("q.queues.Storage.QueueDeleteDestinationForID: %w", err) - } - - if remaining, err := q.queues.Storage.QueueSelectIsMessagePendingSend("Outbox", ref.ID); err != nil { - return fmt.Errorf("q.queues.Storage.QueueSelectIsMessagePendingSend: %w", err) - } else if !remaining { - q.queues.Log.Printf("Moving mail with id '%d' from Outbox to Sent\n", ref.ID) - return q.queues.Storage.MailMove("Outbox", ref.ID, "Sent") - } - - return nil - }(); err != nil { - q.queues.Log.Println("Will retry sending to", q.destination, "later due to error:", err) - // TODO: Send a mail to the inbox on the first instance? - } else { - q.queues.Log.Println("Sent mail from", ref.From, "to", q.destination) + q.finalize(ref) } } } + +func (q *Queue) deliverLocal(ref types.QueuedMail, content io.ReadCloser, size uint32) error { + q.queues.Log.Printf("Local delivery for %s", ref.Rcpt) + + _, toAcc, err := utils.ParseAddress(ref.Rcpt) + toAccPk := utils.EncodeString(toAcc) + if err != nil { + return fmt.Errorf("local delivery: invalid rcpt address: %w", err) + } + if acc, err := q.queues.Storage.AccountGet(toAccPk); acc == nil && err != nil { + return fmt.Errorf("local delivery: account not found") + } + + _ = q.queues.Storage.MailboxCreate(toAccPk, "INBOX") + id, err := q.queues.Storage.MailCreate(toAccPk, "INBOX", content, int(size)) + if err != nil { + return fmt.Errorf("local delivery failed: %w", err) + } + if count, err := q.queues.Storage.MailCount(toAccPk, "INBOX"); err == nil { + q.queues.Notify.NotifyNew(toAccPk, notify.NewEmailEvent(toAccPk, "INBOX", int64(id), int64(count))) + } + + return nil +} + +// Deliver incapsulate SMTP-Transaction +func (q *Queue) deliverRemote(ref types.QueuedMail, content io.ReadCloser, size uint32) error { + dPkb, err := hex.DecodeString(q.destination) + dPk := ed25519.PublicKey(dPkb) + + resp, _, err := q.queues.RPCClient.Call(dPk, &pb.Request{ + Call: &pb.Request_MailPush{ + MailPush: &pb.MailPushRequest{ + From: ref.From, + To: ref.Rcpt, + }, + }, + }, content, size) + + if err != nil { + return fmt.Errorf("rpc call failed: %w", err) + } + + if !resp.Success { + return fmt.Errorf("remote server returned error: %s", resp.Error) + } + + q.queues.Log.Printf("Sender: RPC delivery successful for ID %d to node %s", ref.ID, q.destination) + return nil +} + +func (q *Queue) finalize(ref types.QueuedMail) { + // Delete id from Queue + _ = q.queues.Storage.QueueDeleteDestinationForID(ref.AccountPubKey, q.destination, ref.ID) + + remaining, err := q.queues.Storage.QueueSelectIsMessagePendingSend(ref.AccountPubKey, "Outbox", ref.ID) + if err == nil && !remaining { + q.queues.Log.Printf("Moving mail %d to Sent for %s", ref.ID, ref.AccountPubKey) + _, _ = q.queues.Storage.MailMove(ref.AccountPubKey, "Outbox", ref.ID, "Sent") + } + + _ = q.queues.Storage.MailExpunge(ref.AccountPubKey, "Outbox") +} + +func (q *Queue) handleError(ref types.QueuedMail, err error) { + if errors.Is(err, sql.ErrNoRows) { + q.queues.Log.Printf("Cleaning up orphaned queue ref %d for %s", ref.ID, ref.AccountPubKey) + _ = q.queues.Storage.QueueDeleteDestinationForID(ref.AccountPubKey, q.destination, ref.ID) + } else { + q.queues.Log.Printf("Storage error for %s: %v", ref.AccountPubKey, err) + } +} diff --git a/internal/smtpserver/backend.go b/internal/smtpserver/backend.go index fd7113b..b6a9b2d 100644 --- a/internal/smtpserver/backend.go +++ b/internal/smtpserver/backend.go @@ -9,15 +9,16 @@ package smtpserver import ( - "encoding/hex" + "crypto/ed25519" "fmt" "log" "github.com/emersion/go-smtp" - "github.com/neilalexander/yggmail/internal/config" - "github.com/neilalexander/yggmail/internal/imapserver" + "github.com/neilalexander/yggmail/internal/account" + "github.com/neilalexander/yggmail/internal/notify" "github.com/neilalexander/yggmail/internal/smtpsender" "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/remote" "github.com/neilalexander/yggmail/internal/utils" ) @@ -29,35 +30,49 @@ const ( ) type Backend struct { - Mode BackendMode - Log *log.Logger - Config *config.Config - Queues *smtpsender.Queues - Storage storage.Storage - Notify *imapserver.IMAPNotify + Mode BackendMode + Log *log.Logger + Queues *smtpsender.Queues + Storage storage.Storage + Notify *notify.Notify + Account *account.Manager + NodePublicKey ed25519.PublicKey + // Config *config.Config } func (b *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { switch b.Mode { case BackendModeInternal: // If our username is email-like, then take just the localpart - if pk, err := utils.ParseAddress(username); err == nil { - if !pk.Equal(b.Config.PublicKey) { - return nil, fmt.Errorf("failed to authenticate: wrong domain in username") - } + dPk, aPk, err := utils.ParseAddress(username) + if err != nil { + return nil, fmt.Errorf("failed to authenticate: wrong domain or username") } - username = hex.EncodeToString(b.Config.PublicKey) - // The connection came from our local listener - if authed, err := b.Storage.ConfigTryPassword(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 + + // Work in domain mode: Get account + session, err := b.Account.Login(username, password) + if err != nil { + return nil, fmt.Errorf("failed to autheticate: %w", err) } - defer b.Log.Printf("Authenticated SMTP user from %s as %q\n", state.RemoteAddr.String(), username) + + username = utils.EncodeString(aPk) + var storage storage.Storage + + if !b.NodePublicKey.Equal(dPk) { + storage = remote.NewRemoteStorage(dPk, session, b.Queues.Transport) + b.Log.Println("SMTP L: Authenticate with remote storage as", username) + //return nil, fmt.Errorf("failed to authenticate: wrong domain in username") + } else { + storage = b.Storage + b.Log.Println("SMTP L: Authenticate with local storage as", username) + } + + defer b.Log.Printf("Authenticated SMTP user as %q\n", username) + return &SessionLocal{ backend: b, + session: session, + storage: storage, state: state, }, nil @@ -74,23 +89,34 @@ func (b *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, err 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 - pks, err := hex.DecodeString(state.RemoteAddr.String()) - if err != nil { - return nil, fmt.Errorf("hex.DecodeString: %w", err) - } - remote := hex.EncodeToString(pks) - if state.Hostname != remote { - return nil, fmt.Errorf("you are not who you claim to be") - } + return nil, fmt.Errorf("not expecting anonymous connection on smtp. RPC Only") + // // The connection came from our overlay listener, so we should check + // // that they are who they claim to be - b.Log.Println("Incoming SMTP session from", remote) - return &SessionRemote{ - backend: b, - state: state, - public: pks[:], - }, nil + // // Transport Layer address. Hex + // nodePk, err := hex.DecodeString(state.RemoteAddr.String()) + // if err != nil { + // return nil, fmt.Errorf("hex.DecodeString: %w", err) + // } + // // HELO Hostname. Format + // hostPk, err := utils.DecodeString(state.Hostname) + // if err != nil { + // return nil, fmt.Errorf("utils.DecodeKey: %w", err) + // } + + // nPk := ed25519.PublicKey(nodePk) + // hPk := ed25519.PublicKey(hostPk) + + // if !nPk.Equal(hPk) { + // return nil, fmt.Errorf("you are not who you claim to be") + // } + + // b.Log.Println("SMTP Local: Incoming session from", state.Hostname) + // return &SessionRemote{ + // backend: b, + // state: state, + // public: nPk[:], + // }, nil } return nil, fmt.Errorf("anonymous login failed") diff --git a/internal/smtpserver/session_local.go b/internal/smtpserver/session_local.go index 0282b72..9eee2d4 100644 --- a/internal/smtpserver/session_local.go +++ b/internal/smtpserver/session_local.go @@ -9,20 +9,23 @@ package smtpserver import ( - "bytes" - "encoding/hex" + "crypto/ed25519" "fmt" "io" - "time" - "github.com/emersion/go-message" "github.com/emersion/go-smtp" + "github.com/neilalexander/yggmail/internal/account" + "github.com/neilalexander/yggmail/internal/remote" + "github.com/neilalexander/yggmail/internal/storage" "github.com/neilalexander/yggmail/internal/utils" + "github.com/neilalexander/yggmail/internal/utils/e2ee" ) type SessionLocal struct { backend *Backend + storage storage.Storage state *smtp.ConnectionState + session *account.Session from string rcpt []string } @@ -30,13 +33,12 @@ type SessionLocal struct { func (s *SessionLocal) Mail(from string, opts smtp.MailOptions) error { s.rcpt = s.rcpt[:0] - pk, err := utils.ParseAddress(from) + _, aPk, err := utils.ParseAddress(from) if err != nil { return fmt.Errorf("parseAddress: %w", err) } - - if !pk.Equal(s.backend.Config.PublicKey) { - return fmt.Errorf("not allowed to send outgoing mail as %s", from) + if !aPk.Equal(s.session.Publickey()) { + return fmt.Errorf("not allowed to send outgoing mail as account: %s", utils.EncodeString(aPk)) } s.from = from @@ -49,34 +51,36 @@ func (s *SessionLocal) Rcpt(to string) error { } func (s *SessionLocal) Data(r io.Reader) error { - m, err := message.Read(r) + payload, size, err := remote.ReadRawData(io.NopCloser(r)) if err != nil { - return fmt.Errorf("message.Read: %w", err) + return 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(), - ), - ) - if !m.Header.Has("Date") { - m.Header.Add( - "Date", time.Now().UTC().Format(time.RFC822), - ) + var rcpt []ed25519.PublicKey + for _, r := range s.rcpt { + _, aPk, err := utils.ParseAddress(r) + if err != nil { + s.backend.Log.Println("Data: Parse address: cant parse: ", r) + continue + } + rcpt = append(rcpt, aPk) + } + payload, err = e2ee.EncryptFilter(payload.(io.ReadSeekCloser), s.from, rcpt, s.session.Sign, s.session.Encrypt) + if err != nil { + s.backend.Log.Printf("\nSMTP DATA: error encrypt: %w", err) + return err } - var b bytes.Buffer - if err := m.WriteTo(&b); err != nil { - return fmt.Errorf("m.WriteTo: %w", err) + payload, size, err = remote.ReadRawData(payload) + if err != nil { + return err } - if err := s.backend.Queues.QueueFor(s.from, s.rcpt, b.Bytes()); err != nil { + if err := s.backend.Queues.QueueFor(s.storage, s.session.Username(), s.from, s.rcpt, payload, size); err != nil { return fmt.Errorf("s.backend.Queues.QueueFor: %w", err) } - s.backend.Log.Println("Queued mail for", s.rcpt) + s.backend.Log.Println("SMTP L: Queued mail for", s.rcpt) return nil } diff --git a/internal/smtpserver/session_remote.go b/internal/smtpserver/session_remote.go index 26ee340..ac54e6b 100644 --- a/internal/smtpserver/session_remote.go +++ b/internal/smtpserver/session_remote.go @@ -1,98 +1,119 @@ -/* - * Copyright (c) 2021 Neil Alexander - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - +// /* +// - Copyright (c) 2021 Neil Alexander +// * +// - This Source Code Form is subject to the terms of the Mozilla Public +// - License, v. 2.0. If a copy of the MPL was not distributed with this +// - file, You can obtain one at http://mozilla.org/MPL/2.0/. +// */ package smtpserver -import ( - "bytes" - "crypto/ed25519" - "encoding/hex" - "fmt" - "io" - "time" +// +// +// WAS DEPRECATED BUT ARCHIVED FOR RE-IMPLEMENTATION ON PROTOBUFF +// +// - "github.com/emersion/go-message" - "github.com/emersion/go-smtp" - "github.com/neilalexander/yggmail/internal/utils" -) - -type SessionRemote struct { - backend *Backend - state *smtp.ConnectionState - public ed25519.PublicKey - from string -} - -func (s *SessionRemote) Mail(from string, opts smtp.MailOptions) error { - pk, err := utils.ParseAddress(from) - if err != nil { - return fmt.Errorf("mail.ParseAddress: %w", err) - } - - if remote := s.state.RemoteAddr.String(); hex.EncodeToString(pk) != remote { - return fmt.Errorf("not allowed to send incoming mail as %s", from) - } - - s.from = from - return nil -} - -func (s *SessionRemote) Rcpt(to string) error { - pk, err := utils.ParseAddress(to) - if err != nil { - return fmt.Errorf("mail.ParseAddress: %w", err) - } - - if !pk.Equal(s.backend.Config.PublicKey) { - return fmt.Errorf("unexpected recipient for wrong domain") - } - - 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(), - ), - ) - m.Header.Add( - "Delivery-Date", time.Now().UTC().Format(time.RFC822), - ) - - var b bytes.Buffer - if err := m.WriteTo(&b); err != nil { - return fmt.Errorf("m.WriteTo: %w", err) - } - - if id, err := s.backend.Storage.MailCreate("INBOX", b.Bytes()); err != nil { - return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err) - } else { - s.backend.Log.Printf("Stored new mail from %s", s.from) - - if count, err := s.backend.Storage.MailCount("INBOX"); err == nil { - if err := s.backend.Notify.NotifyNew(id, count); err != nil { - s.backend.Log.Println("Failed to notify:", s.from) - } - } - } - - return nil -} - -func (s *SessionRemote) Reset() {} - -func (s *SessionRemote) Logout() error { - return nil -} +// +// import ( +// "bytes" +// "crypto/ed25519" +// "encoding/hex" +// "fmt" +// "io" +// "time" +// +// "github.com/emersion/go-message" +// "github.com/emersion/go-smtp" +// "github.com/neilalexander/yggmail/internal/notify" +// "github.com/neilalexander/yggmail/internal/utils" +// ) +// +// type SessionRemote struct { +// backend *Backend +// state *smtp.ConnectionState +// public ed25519.PublicKey +// from string +// recipient string +// } +// +// func (s *SessionRemote) Mail(from string, opts smtp.MailOptions) error { +// dPk, _, err := utils.ParseAddress(from) +// if err != nil { +// return fmt.Errorf("mail.ParseAddress: %w", err) +// } +// // Tranport layer work on hex16 addresses +// remote, err := hex.DecodeString(s.state.RemoteAddr.String()) +// +// rPk := ed25519.PublicKey(remote) +// if err != nil { +// return fmt.Errorf("hex.Decode error decode key") +// } +// if !dPk.Equal(rPk) { +// return fmt.Errorf("not allowed to send incoming mail as %s", from) +// } +// +// s.from = from +// return nil +// } +// +// func (s *SessionRemote) Rcpt(to string) error { +// dPk, aPk, err := utils.ParseAddress(to) +// if err != nil { +// return fmt.Errorf("mail.ParseAddress: %w", err) +// } +// +// if !dPk.Equal(s.backend.NodePublicKey) { +// return fmt.Errorf("unexpected recipient for wrong domain or username") +// } +// if _, err := s.backend.Account.GetAccount(utils.EncodeString(aPk)); err != nil { +// return fmt.Errorf("unexpected recipient for wrong domain or username") +// } +// +// s.recipient = utils.EncodeString(aPk) +// 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) +// } +// +// // TODO: Verify Message with Header: +// // Header: Sig-ED25519 +// +// m.Header.Add( +// "Received", fmt.Sprintf("from Yggmail %s; %s", +// utils.EncodeString(s.public), +// time.Now().String(), +// ), +// ) +// m.Header.Add( +// "Delivery-Date", time.Now().UTC().Format(time.RFC822), +// ) +// +// var b bytes.Buffer +// if err := m.WriteTo(&b); err != nil { +// return fmt.Errorf("m.WriteTo: %w", err) +// } +// +// if id, err := s.backend.Storage.MailCreate(s.recipient, "INBOX", b.Bytes()); err != nil { +// return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err) +// } else { +// s.backend.Log.Printf("Stored new mail from %s", s.from) +// +// if count, err := s.backend.Storage.MailCount(s.recipient, "INBOX"); err == nil { +// if err := s.backend.Notify.NotifyNew(s.recipient, notify.NewEmailEvent(s.recipient, "INBOX", int64(id), int64(count))); err != nil { +// s.backend.Log.Println("SMTP R: Failed to notify:", 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 index c6eaf88..1d888e3 100644 --- a/internal/smtpserver/smtp.go +++ b/internal/smtpserver/smtp.go @@ -10,16 +10,16 @@ package smtpserver import ( "github.com/emersion/go-smtp" - "github.com/neilalexander/yggmail/internal/imapserver" + "github.com/neilalexander/yggmail/internal/notify" ) type SMTPServer struct { server *smtp.Server backend smtp.Backend - notify *imapserver.IMAPNotify + notify *notify.Notify } -func NewSMTPServer(backend smtp.Backend, notify *imapserver.IMAPNotify) *SMTPServer { +func NewSMTPServer(backend smtp.Backend, notify *notify.Notify) *SMTPServer { s := &SMTPServer{ server: smtp.NewServer(backend), backend: backend, diff --git a/internal/storage/filestore/filestore.go b/internal/storage/filestore/filestore.go new file mode 100644 index 0000000..868e73d --- /dev/null +++ b/internal/storage/filestore/filestore.go @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2024 JB-SelfCompany / Tyr Contributors + * + * https://github.com/JB-SelfCompany/yggmail + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package filestore + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// FileStore manages file-based storage for large email messages +type FileStore struct { + basePath string + mu sync.RWMutex +} + +// BasePath returns the base path of the file store +func (fs *FileStore) BasePath() string { + fs.mu.RLock() + defer fs.mu.RUnlock() + return fs.basePath +} + +// sanitizeMailboxName removes potentially dangerous characters from mailbox names +func sanitizeMailboxName(mailbox string) string { + // Replace path separators and other dangerous characters + sanitized := strings.ReplaceAll(mailbox, "/", "_") + sanitized = strings.ReplaceAll(sanitized, "\\", "_") + sanitized = strings.ReplaceAll(sanitized, "..", "_") + sanitized = strings.ReplaceAll(sanitized, ":", "_") + sanitized = strings.TrimSpace(sanitized) + + if sanitized == "" { + sanitized = "default" + } + + return sanitized +} + +// NewFileStore creates a new FileStore and initializes directory structure +// Creates subdirectories for different mailboxes (INBOX, Outbox, etc.) +func NewFileStore(basePath string) (*FileStore, error) { + if basePath == "" { + return nil, fmt.Errorf("basePath cannot be empty") + } + if err := os.MkdirAll(basePath, 0755); err != nil { + return nil, fmt.Errorf("failed to create base directory: %w", err) + } + return &FileStore{basePath: basePath}, nil +} + +func genName(aPk string) (ts int64, hs string) { + ts = time.Now().UnixNano() + h := sha256.New() + h.Write(fmt.Appendf([]byte{}, "%d%s", int(ts), aPk)) + hs = hex.EncodeToString(h.Sum(nil))[:16] + return ts, hs +} + +// StoreMail stores an email message from a reader to a file +// Returns the file path where the message was stored and the actual bytes written +// Uses atomic write (temp file + rename) to prevent corruption +func (fs *FileStore) StoreMail(aPk string, mailbox string, reader io.Reader) (string, int64, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + sanitized := sanitizeMailboxName(mailbox) + mailboxPath := filepath.Join(fs.basePath, aPk, sanitized) + + if err := os.MkdirAll(mailboxPath, 0755); err != nil { + return "", 0, fmt.Errorf("failed to create user mailbox directory: %w", err) + } + + ts, hs := genName(aPk) + + // Create temp file in same directory (for atomic rename) + tempFile, err := os.CreateTemp(mailboxPath, fmt.Sprintf(".tmp_%d_%s_*", ts, hs)) + if err != nil { + return "", 0, fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := tempFile.Name() + + //Clean up temp file on error + defer func() { + if tempFile != nil { + tempFile.Close() + os.Remove(tempPath) + } + }() + + // Stream data to temp file with buffering + written, err := io.Copy(tempFile, reader) + if err != nil { + return "", 0, fmt.Errorf("failed to write data: %w", err) + } + + // Log size for debugging + // fmt.Printf("[FileStore] StoreMail: mailID=%d, written=%d bytes (%.2f MB)\n", + // 0, written, float64(written)/(1024*1024)) + // if written == 0 { + // log.Printf("WARNING: empty file written for id=%d", 0) + // } + + // Sync to disk + if err := tempFile.Sync(); err != nil { + return "", 0, fmt.Errorf("failed to sync file: %w", err) + } + + // Close before rename + if err := tempFile.Close(); err != nil { + return "", 0, fmt.Errorf("failed to close temp file: %w", err) + } + tempFile = nil // Prevent defer cleanup + + // Final file path + finalPath := filepath.Join(mailboxPath, fmt.Sprintf("%d_%s.eml", ts, hs)) + + // Atomic rename + if err := os.Rename(tempPath, finalPath); err != nil { + return "", 0, fmt.Errorf("failed to rename temp file: %w", err) + } + + // Return relative path from basePath + relPath, err := filepath.Rel(fs.basePath, finalPath) + if err != nil { + return "", 0, fmt.Errorf("failed to get relative path: %w", err) + } + + return relPath, written, nil +} + +// ReadMail opens and returns a reader for the specified mail file +// Caller is responsible for closing the returned ReadCloser +func (fs *FileStore) ReadMail(location string) (io.ReadCloser, error) { + fs.mu.RLock() + defer fs.mu.RUnlock() + + fullPath := filepath.Join(fs.basePath, location) + + if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(fs.basePath)) { + return nil, fmt.Errorf("invalid path traversal attempt") + } + + return os.Open(fullPath) +} + +// DeleteMail deletes the specified mail file +func (fs *FileStore) DeleteMail(location string) error { + // log.Println("Delete: ", location) + fs.mu.Lock() + defer fs.mu.Unlock() + + fullPath := filepath.Join(fs.basePath, location) + return os.Remove(fullPath) +} + +// MoveMail moves a mail file to a different mailbox with a new ID +// Returns the new relative path +func (fs *FileStore) MoveMail(oldLocation string, newMailbox string) (string, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + oldFullPath := filepath.Join(fs.basePath, oldLocation) + + parts := strings.Split(filepath.ToSlash(oldLocation), "/") + if len(parts) < 3 { + return "", fmt.Errorf("invalid location format") + } + aPk := parts[0] + fileName := parts[len(parts)-1] + + sanitizedNew := sanitizeMailboxName(newMailbox) + newRelPath := filepath.Join(aPk, sanitizedNew, fileName) + newFullPath := filepath.Join(fs.basePath, newRelPath) + + if err := os.MkdirAll(filepath.Dir(newFullPath), 0755); err != nil { + return "", err + } + + if err := os.Rename(oldFullPath, newFullPath); err != nil { + return "", err + } + // log.Printf("Moved file from %s to %s", oldFullPath, newFullPath) + + return newRelPath, nil +} + +// GetTotalSize returns the total size of all stored mail files in bytes +func (fs *FileStore) GetTotalSize() (int64, error) { + fs.mu.RLock() + defer fs.mu.RUnlock() + + var totalSize int64 + + err := filepath.Walk(fs.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".eml") { + totalSize += info.Size() + } + return nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to calculate total size: %w", err) + } + + return totalSize, nil +} + +// CleanupOrphanedFiles removes mail files that are not referenced in the database +// This requires access to the storage layer to query existing mail records +// Returns the number of files deleted and total bytes freed +func (fs *FileStore) CleanupOrphanedFiles(validPaths map[string]bool) (int, int64, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + deletedCount := 0 + var deletedSize int64 + + err := filepath.Walk(fs.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories and non-.eml files + if info.IsDir() || !strings.HasSuffix(info.Name(), ".eml") { + return nil + } + + // Get relative path + relPath, err := filepath.Rel(fs.basePath, path) + if err != nil { + return err + } + + // Check if this file is in the valid paths map + if !validPaths[relPath] { + // Orphaned file - delete it + size := info.Size() + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to delete orphaned file %s: %w", relPath, err) + } + deletedCount++ + deletedSize += size + } + + return nil + }) + + if err != nil { + return deletedCount, deletedSize, fmt.Errorf("cleanup failed: %w", err) + } + + return deletedCount, deletedSize, nil +} + +// GetMailboxSize returns the total size of files in a specific mailbox +func (fs *FileStore) GetMailboxSize(aPk string, mailbox string) (int64, error) { + fs.mu.RLock() + defer fs.mu.RUnlock() + + sanitized := sanitizeMailboxName(mailbox) + mailboxPath := filepath.Join(fs.basePath, aPk, sanitized) + + var totalSize int64 + err := filepath.Walk(mailboxPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".eml") { + totalSize += info.Size() + } + return nil + }) + return totalSize, err +} diff --git a/internal/storage/local/storage.go b/internal/storage/local/storage.go new file mode 100644 index 0000000..d6a7768 --- /dev/null +++ b/internal/storage/local/storage.go @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package local + +import ( + "bytes" + "io" + "log" + "os" + + "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/types" +) + +type LocalStorage struct { + storage.Storage + db storage.MailStore + blobs storage.BlobStore + threshold int64 + log log.Logger +} + +func NewLocalStorage(store storage.MailStore, blobs storage.BlobStore, threshold int64) storage.Storage { + if threshold <= 0 { + threshold = 65536 // Default 64KB + } + return &LocalStorage{ + Storage: store, + db: store, + blobs: blobs, + threshold: threshold, + log: *log.New(os.Stdout, "DB :: ", 0555), + } +} + +type body struct { + io.ReadCloser + io.Seeker +} + +func (l *LocalStorage) MailSelect(pk string, mailbox string, id int) (int, *types.Mail, error) { + seq, mail, err := l.db.MailSelect(pk, mailbox, id) + if err != nil { + return 0, nil, err + } + + if mail.MailFile != "" { + l.log.Println("Message Blob large take from ", mail.MailFile) + rc, err := l.blobs.ReadMail(mail.MailFile) + if err != nil { + return 0, nil, err + } + mail.Body = rc + } else { + l.log.Println("Message Blob less take from DB") + br := bytes.NewReader(mail.RawData) + mail.Body = body{io.NopCloser(br), br} + } + return seq, mail, nil +} + +func (l *LocalStorage) MailCreate(pk string, mailbox string, r io.Reader, size int) (int, error) { + if int64(size) > l.threshold { + location, written, err := l.blobs.StoreMail(pk, mailbox, r) + if err != nil { + return 0, err + } + l.log.Println("Message Blob large save in ", location) + return l.db.MailCreateRaw(pk, mailbox, nil, location, written) + } + l.log.Println("Message Blob less save in DB") + return l.db.MailCreateRaw(pk, mailbox, r, "", int64(size)) +} + +func (l *LocalStorage) MailMove(pk string, mailbox string, id int, destination string) (int, error) { + _, mail, err := l.MailSelect(pk, mailbox, id) + if err != nil { + return 0, err + } + + id, err = l.db.MailMove(pk, mailbox, id, destination) + if err != nil { + return 0, err + } + + if mail.MailFile != "" { + _, err := l.blobs.MoveMail(mail.MailFile, destination) + if err != nil { + return 0, err + } + } + return id, nil +} + +func (s *LocalStorage) MailDelete(pk string, mailbox string, id int) error { + log.Println("Delete: ", pk, mailbox, id) + _, mail, err := s.Storage.MailSelect(pk, mailbox, id) + if err == nil && mail.MailFile != "" { + _ = s.blobs.DeleteMail(mail.MailFile) + } + return s.Storage.MailDelete(pk, mailbox, id) +} diff --git a/internal/storage/remote/mail.go b/internal/storage/remote/mail.go new file mode 100644 index 0000000..6ee1a2b --- /dev/null +++ b/internal/storage/remote/mail.go @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "fmt" + "io" + "time" + + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/storage/types" + "github.com/neilalexander/yggmail/internal/utils" +) + +// Helpers + +func (s *RemoteStorage) getStatus(pk, mailbox string) (*pb.MailboxStatus, error) { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxStatus{ + MboxStatus: &pb.MailboxStatusRequest{Name: mailbox}, + }, + }, nil, 0) // No payload for status + if err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf(resp.Error) + } + return resp.GetMboxStatus(), nil +} + +// Mail Read + +func (s *RemoteStorage) MailCount(pk string, mailbox string) (int, error) { + st, err := s.getStatus(pk, mailbox) + if err != nil { + return 0, err + } + return int(st.MessagesCount), nil +} + +func (s *RemoteStorage) MailUnseen(pk string, mailbox string) (int, error) { + st, err := s.getStatus(pk, mailbox) + if err != nil { + return 0, err + } + return int(st.UnseenCount), nil +} + +func (s *RemoteStorage) MailNextID(pk string, mailbox string) (int, error) { + st, err := s.getStatus(pk, mailbox) + if err != nil { + return 0, err + } + return int(st.UidNext), nil +} + +func (s *RemoteStorage) MailSelect(pk string, mailbox string, id int) (int, *types.Mail, error) { + resp, payload, err := s.Client.Call(s.targetNode, &pb.Request{ + Call: &pb.Request_MailFetch{ + MailFetch: &pb.MailFetchRequest{ + Mailbox: mailbox, + Ids: []uint32{uint32(id)}, + }, + }, + }, nil, 0) + + if err != nil { + return 0, nil, fmt.Errorf("rpc select failed: %w", err) + } + if !resp.Success || len(resp.GetMailList().Items) == 0 { + return 0, nil, fmt.Errorf("message not found") + } + + m := resp.GetMailList().Items[0] + + mail := &types.Mail{ + ID: int(m.Id), + Size: m.Size, + Date: time.Unix(m.Date, 0), + Seen: m.Seen, + Answered: m.Answered, + Flagged: m.Flagged, + Deleted: m.Deleted, + } + + if payload != nil { + mail.Body = payload.R + } + + return int(m.Seq), mail, nil +} + +// Mail Create + +func (s *RemoteStorage) MailCreate(pk string, mailbox string, content io.Reader, size int) (int, error) { + addr := fmt.Sprintf("%s@%s", pk, utils.EncodeString(s.targetNode)) + + resp, _, err := s.Client.Call(s.targetNode, &pb.Request{ + Call: &pb.Request_MailPush{ + MailPush: &pb.MailPushRequest{ + From: addr, + To: addr, + Mailbox: mailbox, + }, + }, + }, io.NopCloser(content), uint32(size)) + + if err != nil { + return 0, fmt.Errorf("rpc push failed: %w", err) + } + if !resp.Success { + return 0, fmt.Errorf("remote storage error: %s", resp.Error) + } + + return int(resp.GetIdResult()), nil +} + +func (s *RemoteStorage) MailUpdateFlags(pk string, mailbox string, id int, seen, answered, flagged, deleted bool) error { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailFlags{ + MailFlags: &pb.MailUpdateFlagsRequest{ + Mailbox: mailbox, Id: uint32(id), + Seen: seen, Answered: answered, Flagged: flagged, Deleted: deleted, + }, + }, + }, nil, 0) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf(resp.Error) + } + return nil +} + +// Managements + +func (s *RemoteStorage) MailDelete(pk string, mailbox string, id int) error { + _, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailDel{ + MailDel: &pb.MailDeleteRequest{Mailbox: mailbox, Id: uint32(id)}, + }, + }, nil, 0) + return err +} + +func (s *RemoteStorage) MailMove(pk string, mailbox string, id int, dest string) (int, error) { + _, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailMove{ + MailMove: &pb.MailMoveRequest{ + Mailbox: mailbox, + Id: uint32(id), + DestinationMailbox: dest, + }, + }, + }, nil, 0) + return 0, err +} + +func (s *RemoteStorage) MailExpunge(pk string, mailbox string) error { + _, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailExpunge{ + MailExpunge: &pb.MailExpungeRequest{Mailbox: mailbox}, + }, + }, nil, 0) + return err +} + +func (s *RemoteStorage) MailIDForSeq(pk string, mailbox string, seq int) (int, error) { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailIdForSeq{ + MailIdForSeq: &pb.MailIdForSeqRequest{ + Mailbox: mailbox, + Seq: uint32(seq), + }, + }, + }, nil, 0) + if err != nil { + return 0, err + } + if !resp.Success { + return 0, fmt.Errorf("remote error: %s", resp.Error) + } + return int(resp.GetIdResult()), nil +} + +func (s *RemoteStorage) MailSearch(pk string, mailbox string) ([]uint32, error) { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailSearch{ + MailSearch: &pb.MailSearchRequest{Mailbox: mailbox}, + }, + }, nil, 0) + if err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf("remote error: %s", resp.Error) + } + + var ids []uint32 + for _, item := range resp.GetMailList().Items { + ids = append(ids, item.Id) + } + return ids, nil +} diff --git a/internal/storage/remote/mailbox.go b/internal/storage/remote/mailbox.go new file mode 100644 index 0000000..415d576 --- /dev/null +++ b/internal/storage/remote/mailbox.go @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "fmt" + + pb "github.com/neilalexander/yggmail/internal/remote/types" +) + +func (s *RemoteStorage) MailboxList(pk string, onlySubscribed bool) ([]string, error) { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxList{ + MboxList: &pb.MailboxListRequest{OnlySubscribed: onlySubscribed}, + }, + }, nil, 0) + if err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf(resp.Error) + } + return resp.GetMboxList().Names, nil +} + +func (s *RemoteStorage) MailboxSelect(pk string, mailbox string) (bool, error) { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxStatus{ + MboxStatus: &pb.MailboxStatusRequest{Name: mailbox}, + }, + }, nil, 0) + if err != nil { + return false, err + } + return resp.Success, nil +} + +func (s *RemoteStorage) MailboxCreate(pk string, name string) error { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxCreate{ + MboxCreate: &pb.MailboxCreateRequest{Name: name}, + }, + }, nil, 0) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf(resp.Error) + } + return nil +} + +func (s *RemoteStorage) MailboxDelete(pk string, name string) error { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxDelete{ + MboxDelete: &pb.MailboxDeleteRequest{Name: name}, + }, + }, nil, 0) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf(resp.Error) + } + return nil +} + +func (s *RemoteStorage) MailboxRename(pk string, old, new string) error { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxRename{ + MboxRename: &pb.MailboxRenameRequest{OldName: old, NewName: new}, + }, + }, nil, 0) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf(resp.Error) + } + return nil +} + +func (s *RemoteStorage) MailboxSubscribe(pk string, name string, subscribed bool) error { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MboxSub{ + MboxSub: &pb.MailboxSubscribeRequest{Name: name, Subscribed: subscribed}, + }, + }, nil, 0) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf(resp.Error) + } + return nil +} diff --git a/internal/storage/remote/queue.go b/internal/storage/remote/queue.go new file mode 100644 index 0000000..5958408 --- /dev/null +++ b/internal/storage/remote/queue.go @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "fmt" + + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/storage/types" +) + +func (s *RemoteStorage) QueueInsertDestinationForID(pk string, dest string, id int, from, rcpt string) error { + _, _, err := s.Call(&pb.Request{ + Call: &pb.Request_QueueInsert{ + QueueInsert: &pb.QueueInsertRequest{Destination: dest, Id: uint32(id), From: from, Rcpt: rcpt}, + }, + }, nil, 0) + if err != nil { + return err + } + return nil +} + +func (s *RemoteStorage) QueueListDestinations() ([]string, error) { return nil, nil } +func (s *RemoteStorage) QueueMailIDsForDestination(dest string) ([]types.QueuedMail, error) { + return nil, nil +} +func (s *RemoteStorage) QueueDeleteDestinationForID(pk, dest string, id int) error { return nil } +func (s *RemoteStorage) QueueSelectIsMessagePendingSend(pk, mailbox string, id int) (bool, error) { + _, mail, err := s.MailSelect(pk, mailbox, id) + if err != nil { + return false, fmt.Errorf("storage.MailSelect: %w", err) + } + if mail != nil { + return true, nil + } + return false, nil +} diff --git a/internal/storage/remote/storage.go b/internal/storage/remote/storage.go new file mode 100644 index 0000000..2d7be84 --- /dev/null +++ b/internal/storage/remote/storage.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "crypto/ed25519" + "io" + + "github.com/neilalexander/yggmail/internal/account" + "github.com/neilalexander/yggmail/internal/remote" + pb "github.com/neilalexander/yggmail/internal/remote/types" + "github.com/neilalexander/yggmail/internal/storage" + "github.com/neilalexander/yggmail/internal/storage/types" + "github.com/neilalexander/yggmail/internal/transport" +) + +type RemoteStorage struct { + Client *remote.Client + targetNode ed25519.PublicKey +} + +func NewRemoteStorage(targetNode ed25519.PublicKey, sess *account.Session, tr transport.Transport) storage.Storage { + return &RemoteStorage{ + Client: remote.NewClient(sess.Publickey(), sess.Sign, tr), + targetNode: targetNode, + } +} + +func (s *RemoteStorage) Call(req *pb.Request, r io.ReadCloser, size uint32) (*pb.Response, *remote.Payload, error) { + return s.Client.Call(s.targetNode, req, r, size) +} + +func (s *RemoteStorage) ConfigGet(k string) (string, error) { return "", nil } +func (s *RemoteStorage) ConfigSet(k, v string) error { return nil } +func (s *RemoteStorage) ConfigSetPassword(p string) error { return nil } +func (s *RemoteStorage) ConfigTryPassword(p string) (bool, error) { return false, nil } + +func (s *RemoteStorage) AccountTryPassword(pk, p string) (bool, error) { return false, nil } +func (s *RemoteStorage) AccountGet(pk string) (*types.Account, error) { return nil, nil } +func (s *RemoteStorage) AccountList() ([]*types.Account, error) { return nil, nil } +func (s *RemoteStorage) AccountCreate(acc *types.Account) error { return nil } +func (s *RemoteStorage) AccountDelete(pk string) error { return nil } diff --git a/internal/storage/remote/watch.go b/internal/storage/remote/watch.go new file mode 100644 index 0000000..884d2cc --- /dev/null +++ b/internal/storage/remote/watch.go @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package remote + +import ( + "fmt" + + pb "github.com/neilalexander/yggmail/internal/remote/types" +) + +func (s *RemoteStorage) Watch(pk, mailbox string) (uint32, uint32, error) { + resp, _, err := s.Call(&pb.Request{ + Call: &pb.Request_MailWatch{ + MailWatch: &pb.WatchRequest{Mailbox: mailbox}, + }, + }, nil, 0) + if err != nil { + return 0, 0, err + } + if !resp.Success { + return 0, 0, fmt.Errorf("remote error: %s", resp.Error) + } + status := resp.GetMboxStatus() + if status != nil { + return status.UidNext, status.MessagesCount, nil + } + return 0, 0, nil +} diff --git a/internal/storage/sqlite3/sqlite3.go b/internal/storage/sqlite3/sqlite3.go index d64675d..53435e9 100644 --- a/internal/storage/sqlite3/sqlite3.go +++ b/internal/storage/sqlite3/sqlite3.go @@ -21,12 +21,14 @@ type SQLite3Storage struct { *TableMailboxes *TableMails *TableQueue + *TableAccounts db *sql.DB writer *Writer } func NewSQLite3StorageStorage(filename string) (*SQLite3Storage, error) { - db, err := sql.Open("sqlite3", "file:"+filename+"?_foreign_keys=on") + db, err := sql.Open("sqlite3", "file:"+filename+"?_foreign_keys=on&mode=rwc&_mmap_size=0") + db.Exec("PRAGMA cache_size = -10000;") // ~10 МБ if err != nil { return nil, fmt.Errorf("sql.Open: %w", err) } @@ -40,6 +42,11 @@ func NewSQLite3StorageStorage(filename string) (*SQLite3Storage, error) { if err != nil { return nil, fmt.Errorf("NewTableConfig: %w", err) } + s.TableAccounts, err = NewTableAccounts(db, s.writer) + if err != nil { + return nil, fmt.Errorf("NewTableAccounts: %w", err) + } + s.TableMailboxes, err = NewTableMailboxes(db, s.writer) if err != nil { return nil, fmt.Errorf("NewTableMailboxes: %w", err) diff --git a/internal/storage/sqlite3/table_accounts.go b/internal/storage/sqlite3/table_accounts.go new file mode 100644 index 0000000..00b1441 --- /dev/null +++ b/internal/storage/sqlite3/table_accounts.go @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2024 Your Name / Yggmail Contributor + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package sqlite3 + +import ( + "database/sql" + "fmt" + + "github.com/neilalexander/yggmail/internal/storage/types" +) + +type TableAccounts struct { + db *sql.DB + writer *Writer + selectAccount *sql.Stmt + listAccounts *sql.Stmt + createAccount *sql.Stmt + updateAccount *sql.Stmt + deleteAccount *sql.Stmt +} + +const accountsSchema = ` + CREATE TABLE IF NOT EXISTS accounts ( + pubkey TEXT NOT NULL, + aliases TEXT, + passwd_hash TEXT, + private_key TEXT, + PRIMARY KEY(pubkey) + ); +` + +const accountsSelect = ` + SELECT pubkey, aliases, passwd_hash, private_key FROM accounts WHERE pubkey = $1 +` + +const accountsList = ` + SELECT pubkey, aliases, passwd_hash, private_key FROM accounts +` + +const accountsCreate = ` + INSERT OR IGNORE INTO accounts (pubkey, aliases, passwd_hash, private_key) VALUES($1, $2, $3, $4) +` + +const accountsUpdate = ` + UPDATE accounts SET aliases = $1, passwd_hash = $2, private_key = $3 WHERE pubkey = $4 +` + +const accountsDelete = ` + DELETE FROM accounts WHERE pubkey = $1 +` + +func NewTableAccounts(db *sql.DB, writer *Writer) (*TableAccounts, error) { + t := &TableAccounts{ + db: db, + writer: writer, + } + + _, err := db.Exec(accountsSchema) + if err != nil { + return nil, fmt.Errorf("db.Exec: %w", err) + } + + t.selectAccount, err = db.Prepare(accountsSelect) + if err != nil { + return nil, fmt.Errorf("db.Prepare(accountsSelect): %w", err) + } + + t.listAccounts, err = db.Prepare(accountsList) + if err != nil { + return nil, fmt.Errorf("db.Prepare(accountsList): %w", err) + } + + t.createAccount, err = db.Prepare(accountsCreate) + if err != nil { + return nil, fmt.Errorf("db.Prepare(accountsCreate): %w", err) + } + + t.updateAccount, err = db.Prepare(accountsUpdate) + if err != nil { + return nil, fmt.Errorf("db.Prepare(accountsUpdate): %w", err) + } + + t.deleteAccount, err = db.Prepare(accountsDelete) + if err != nil { + return nil, fmt.Errorf("db.Prepare(accountsDelete): %w", err) + } + + return t, nil +} + +func (t *TableAccounts) AccountGet(pubkey string) (*types.Account, error) { + row := t.selectAccount.QueryRow(pubkey) + + acc := &types.Account{} + err := row.Scan(&acc.PublicKey, &acc.Aliases, &acc.PasswdHash, &acc.Privatekey) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // Аккаунт не найден + } + return nil, fmt.Errorf("row.Scan: %w", err) + } + + return acc, nil +} + +func (t *TableAccounts) AccountList() ([]*types.Account, error) { + rows, err := t.listAccounts.Query() + if err != nil { + return nil, fmt.Errorf("t.listAccounts.Query: %w", err) + } + defer rows.Close() + + var accounts []*types.Account + for rows.Next() { + acc := &types.Account{} + if err := rows.Scan(&acc.PublicKey, &acc.Aliases, &acc.PasswdHash, &acc.Privatekey); err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + accounts = append(accounts, acc) + } + return accounts, nil +} + +func (t *TableAccounts) AccountCreate(acc *types.Account) error { + // TODO: Validate that PublicKey is a valid hex before storing + return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { + _, err := t.createAccount.Exec(acc.PublicKey, acc.Aliases, acc.PasswdHash, acc.Privatekey) + return err + }) +} + +func (t *TableAccounts) AccountUpdate(acc *types.Account) error { + // TODO: Ensure we don't accidentally overwrite PrivateKey with empty string if not provided in update + return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { + _, err := t.updateAccount.Exec(acc.Aliases, acc.PasswdHash, acc.Privatekey, acc.PublicKey) + return err + }) +} + +func (t *TableAccounts) AccountDelete(pubkey string) error { + // TODO: Delete associated mails in TableMails when an account is deleted? + // Or use ON DELETE CASCADE in future schema migrations. + return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { + _, err := t.deleteAccount.Exec(pubkey) + return err + }) +} diff --git a/internal/storage/sqlite3/table_mailboxes.go b/internal/storage/sqlite3/table_mailboxes.go index 71b19e1..7f45d49 100644 --- a/internal/storage/sqlite3/table_mailboxes.go +++ b/internal/storage/sqlite3/table_mailboxes.go @@ -1,5 +1,6 @@ /* - * Copyright (c) 2021 Neil Alexander + * Copyright (c) 2024 Your Name / Yggmail Contributor + * (Original Copyright (c) 2021 Neil Alexander) * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -25,40 +26,43 @@ type TableMailboxes struct { subscribeMailbox *sql.Stmt } +// Мы изменили схему: PRIMARY KEY теперь составной (аккаунт + папка) const mailboxesSchema = ` CREATE TABLE IF NOT EXISTS mailboxes ( - mailbox TEXT NOT NULL DEFAULT('INBOX'), - subscribed BOOLEAN NOT NULL DEFAULT 1, - PRIMARY KEY(mailbox) + account_pubkey TEXT NOT NULL, + mailbox TEXT NOT NULL DEFAULT('INBOX'), + subscribed BOOLEAN NOT NULL DEFAULT 1, + PRIMARY KEY(account_pubkey, mailbox), + FOREIGN KEY(account_pubkey) REFERENCES accounts(pubkey) ON DELETE CASCADE ); ` const mailboxesList = ` - SELECT mailbox FROM mailboxes + SELECT mailbox FROM mailboxes WHERE account_pubkey = $1 ` const mailboxesListSubscribed = ` - SELECT mailbox FROM mailboxes WHERE subscribed = 1 + SELECT mailbox FROM mailboxes WHERE account_pubkey = $1 AND subscribed = 1 ` const mailboxesSelect = ` - SELECT mailbox FROM mailboxes WHERE mailbox = $1 + SELECT mailbox FROM mailboxes WHERE account_pubkey = $1 AND mailbox = $2 ` const mailboxesCreate = ` - INSERT OR IGNORE INTO mailboxes (mailbox) VALUES($1) + INSERT OR IGNORE INTO mailboxes (account_pubkey, mailbox) VALUES($1, $2) ` const mailboxesRename = ` - UPDATE mailboxes SET mailbox = $1 WHERE mailbox = $2 + UPDATE mailboxes SET mailbox = $2 WHERE account_pubkey = $1 AND mailbox = $3 ` const mailboxesDelete = ` - DELETE FROM mailboxes WHERE mailbox = $1 + DELETE FROM mailboxes WHERE account_pubkey = $1 AND mailbox = $2 ` const mailboxesSubscribe = ` - UPDATE mailboxes SET subscribed = $1 WHERE mailbox = $2 + UPDATE mailboxes SET subscribed = $2 WHERE account_pubkey = $1 AND mailbox = $3 ` func NewTableMailboxes(db *sql.DB, writer *Writer) (*TableMailboxes, error) { @@ -72,11 +76,11 @@ func NewTableMailboxes(db *sql.DB, writer *Writer) (*TableMailboxes, error) { } t.listMailboxes, err = db.Prepare(mailboxesList) if err != nil { - return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err) + return nil, fmt.Errorf("db.Prepare(mailboxesList): %w", err) } t.listMailboxesSubscribed, err = db.Prepare(mailboxesListSubscribed) if err != nil { - return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err) + return nil, fmt.Errorf("db.Prepare(mailboxesListSubscribed): %w", err) } t.selectMailboxes, err = db.Prepare(mailboxesSelect) if err != nil { @@ -101,16 +105,19 @@ func NewTableMailboxes(db *sql.DB, writer *Writer) (*TableMailboxes, error) { return t, nil } -func (t *TableMailboxes) MailboxList(onlySubscribed bool) ([]string, error) { +// Теперь все методы принимают accountPubKey первым аргументом + +func (t *TableMailboxes) MailboxList(accountPubKey string, onlySubscribed bool) ([]string, error) { stmt := t.listMailboxes if onlySubscribed { stmt = t.listMailboxesSubscribed } - rows, err := stmt.Query() + rows, err := stmt.Query(accountPubKey) if err != nil { return nil, fmt.Errorf("t.listMailboxes.Query: %w", err) } - defer rows.Close() // nolint:errcheck + defer rows.Close() + var mailboxes []string for rows.Next() { var mailbox string @@ -122,48 +129,47 @@ func (t *TableMailboxes) MailboxList(onlySubscribed bool) ([]string, error) { return mailboxes, nil } -func (t *TableMailboxes) MailboxSelect(mailbox string) (bool, error) { - row := t.selectMailboxes.QueryRow(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 - } +func (t *TableMailboxes) MailboxSelect(accountPubKey string, mailbox string) (bool, error) { + row := t.selectMailboxes.QueryRow(accountPubKey, mailbox) var got string - if err := row.Scan(&got); err != nil { + err := row.Scan(&got) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } return false, fmt.Errorf("row.Scan: %w", err) } return mailbox == got, nil } -func (t *TableMailboxes) MailboxCreate(name string) error { +func (t *TableMailboxes) MailboxCreate(accountPubKey string, name string) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.createMailbox.Exec(name) + _, err := t.createMailbox.Exec(accountPubKey, name) return err }) } -func (t *TableMailboxes) MailboxRename(old, new string) error { +func (t *TableMailboxes) MailboxRename(accountPubKey string, old, new string) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.renameMailbox.Exec(old, new) + _, err := t.renameMailbox.Exec(accountPubKey, new, old) return err }) } -func (t *TableMailboxes) MailboxDelete(name string) error { +func (t *TableMailboxes) MailboxDelete(accountPubKey string, name string) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.deleteMailbox.Exec(name) + _, err := t.deleteMailbox.Exec(accountPubKey, name) return err }) } -func (t *TableMailboxes) MailboxSubscribe(name string, subscribed bool) error { +func (t *TableMailboxes) MailboxSubscribe(accountPubKey string, name string, subscribed bool) error { sn := 1 if !subscribed { sn = 0 } return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.subscribeMailbox.Exec(sn, name) + _, err := t.subscribeMailbox.Exec(accountPubKey, sn, name) return err }) } diff --git a/internal/storage/sqlite3/table_mails.go b/internal/storage/sqlite3/table_mails.go index f2edc98..5292bb6 100644 --- a/internal/storage/sqlite3/table_mails.go +++ b/internal/storage/sqlite3/table_mails.go @@ -1,16 +1,18 @@ /* - * Copyright (c) 2021 Neil Alexander + * (Original Copyright (c) 2021 Neil Alexander) * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package sqlite3 import ( + "bytes" "database/sql" "fmt" + "io" "time" "github.com/neilalexander/yggmail/internal/storage/types" @@ -34,84 +36,89 @@ type TableMails struct { } const mailsSchema = ` - CREATE TABLE IF NOT EXISTS mails ( - 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 (mailbox, id), - FOREIGN KEY (mailbox) REFERENCES mailboxes(mailbox) ON DELETE CASCADE ON UPDATE CASCADE - ); + CREATE TABLE IF NOT EXISTS mails ( + account_pubkey TEXT NOT NULL, + mailbox TEXT NOT NULL, + id INTEGER NOT NULL DEFAULT 1, + mail BLOB NOT NULL, + mail_file TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + datetime INTEGER NOT NULL, + seen BOOLEAN NOT NULL DEFAULT 0, + answered BOOLEAN NOT NULL DEFAULT 0, + flagged BOOLEAN NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (account_pubkey, mailbox, id), + FOREIGN KEY (account_pubkey, mailbox) REFERENCES mailboxes(account_pubkey, mailbox) ON DELETE CASCADE ON UPDATE CASCADE + ); - CREATE VIEW IF NOT EXISTS inboxes AS SELECT * FROM ( - SELECT ROW_NUMBER() OVER (PARTITION BY mailbox) AS seq, * FROM mails - ) - ORDER BY mailbox, id; + -- View for sequential numbering (important for IMAP FETCH sequence numbers) + CREATE VIEW IF NOT EXISTS inboxes AS SELECT * FROM ( + SELECT ROW_NUMBER() OVER (PARTITION BY account_pubkey, mailbox ORDER BY id) AS seq, * FROM mails + ) + ORDER BY account_pubkey, mailbox, id; ` const selectMailsStmt = ` - SELECT * FROM inboxes - ORDER BY mailbox, id + SELECT seq, id, mail, mail_file, size, datetime, seen, answered, flagged, deleted FROM inboxes + WHERE account_pubkey = $1 AND mailbox = $2 + ORDER BY id ` const selectMailStmt = ` - SELECT seq, id, mail, datetime, seen, answered, flagged, deleted FROM inboxes - WHERE mailbox = $1 AND id = $2 - ORDER BY mailbox, id -` - -const selectMailCountStmt = ` - SELECT COUNT(*) FROM mails WHERE mailbox = $1 -` - -const selectMailUnseenStmt = ` - SELECT COUNT(*) FROM mails WHERE mailbox = $1 AND seen = 0 -` - -const searchMailStmt = ` - SELECT id FROM mails - WHERE mailbox = $1 - ORDER BY mailbox, id + SELECT seq, id, mail, mail_file, size, datetime, seen, answered, flagged, deleted FROM inboxes + WHERE account_pubkey = $1 AND mailbox = $2 AND id = $3 ` const insertMailStmt = ` - INSERT INTO mails (mailbox, id, mail, datetime) VALUES( - $1, ( - SELECT IFNULL(MAX(id)+1,1) AS id FROM mails - WHERE mailbox = $1 - ), $2, $3 - ) - RETURNING id; + INSERT INTO mails (account_pubkey, mailbox, id, mail, mail_file, size, datetime) VALUES( + $1, $2, ( + SELECT IFNULL(MAX(id)+1,1) AS id FROM mails + WHERE account_pubkey = $1 AND mailbox = $2 + ), $3, $4, $5, $6 + ) + RETURNING id; +` + +const selectMailCountStmt = ` + SELECT COUNT(*) FROM mails WHERE account_pubkey = $1 AND mailbox = $2 +` + +const selectMailUnseenStmt = ` + SELECT COUNT(*) FROM mails WHERE account_pubkey = $1 AND mailbox = $2 AND seen = 0 +` + +const searchMailStmt = ` + SELECT id FROM mails + WHERE account_pubkey = $1 AND mailbox = $2 + ORDER BY id ` const selectIDForSeqStmt = ` - SELECT id FROM inboxes - WHERE mailbox = $1 AND seq = $2 + SELECT id FROM inboxes + WHERE account_pubkey = $1 AND mailbox = $2 AND seq = $3 ` -const selectMailNextID = ` - SELECT IFNULL(MAX(id)+1,1) AS id FROM mails - WHERE mailbox = $1 +const selectMailNextIDStmt = ` + SELECT IFNULL(MAX(id)+1,1) AS id FROM mails + WHERE account_pubkey = $1 AND mailbox = $2 ` const updateMailFlagsStmt = ` - UPDATE mails SET seen = $1, answered = $2, flagged = $3, deleted = $4 WHERE mailbox = $5 AND id = $6 + UPDATE mails SET seen = $1, answered = $2, flagged = $3, deleted = $4 + WHERE account_pubkey = $5 AND mailbox = $6 AND id = $7 ` const deleteMailStmt = ` - UPDATE mails SET deleted = 1 WHERE mailbox = $1 AND id = $2 + UPDATE mails SET deleted = 1 WHERE account_pubkey = $1 AND mailbox = $2 AND id = $3 ` const expungeMailStmt = ` - DELETE FROM mails WHERE mailbox = $1 AND deleted = 1 + DELETE FROM mails WHERE account_pubkey = $1 AND mailbox = $2 AND deleted = 1 ` const moveMailStmt = ` - UPDATE mails SET mailbox = $1 WHERE mailbox = $2 AND id = $3 + UPDATE mails SET mailbox = $3, id = $4 WHERE account_pubkey = $1 AND mailbox = $2 AND id = $5 ` func NewTableMails(db *sql.DB, writer *Writer) (*TableMails, error) { @@ -123,84 +130,110 @@ func NewTableMails(db *sql.DB, writer *Writer) (*TableMails, error) { 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) + return nil, err } t.selectMail, err = db.Prepare(selectMailStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(selectMailStmt): %w", err) + return nil, err } - t.selectMailNextID, err = db.Prepare(selectMailNextID) + t.selectMailNextID, err = db.Prepare(selectMailNextIDStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(selectMailNextID): %w", err) + return nil, err } t.selectIDForSeq, err = db.Prepare(selectIDForSeqStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err) + return nil, err } t.searchMail, err = db.Prepare(searchMailStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err) + return nil, err } t.createMail, err = db.Prepare(insertMailStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(insertMailStmt): %w", err) + return nil, err } t.updateMailFlags, err = db.Prepare(updateMailFlagsStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(updateMailSeenStmt): %w", err) + return nil, err } t.deleteMail, err = db.Prepare(deleteMailStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(deleteMailStmt): %w", err) + return nil, err } t.expungeMail, err = db.Prepare(expungeMailStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(expungeMailStmt): %w", err) + return nil, err } t.countMails, err = db.Prepare(selectMailCountStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(selectMailCountStmt): %w", err) + return nil, err } t.countUnseenMails, err = db.Prepare(selectMailUnseenStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(selectMailUnseenStmt): %w", err) + return nil, err } t.moveMail, err = db.Prepare(moveMailStmt) if err != nil { - return nil, fmt.Errorf("db.Prepare(moveMailStmt): %w", err) + return nil, err } + return t, nil } -func (t *TableMails) MailCreate(mailbox string, data []byte) (int, error) { +// MailRawCreate inserts mail metadata. +// data: content blob (can be nil/empty if stored in mailFile). +// mailFile: relative path in the BlobStore. +// size: pre-calculated size of the full message. +func (t *TableMails) MailCreateRaw(accountPubKey string, mailbox string, data io.Reader, mailFile string, size int64) (int, error) { var id int err := t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - return t.createMail.QueryRow(mailbox, data, time.Now().Unix()).Scan(&id) + if data == nil { + data = bytes.NewBuffer(nil) + } + body, err := io.ReadAll(data) + if err != nil { + return fmt.Errorf("MailRawCreate: error read data: %w", err) + } + // Use empty slice instead of nil to satisfy NOT NULL constraint if necessary + if body == nil { + body = []byte{} + } + return t.createMail.QueryRow(accountPubKey, mailbox, body, mailFile, size, time.Now().Unix()).Scan(&id) }) return id, err } -func (t *TableMails) MailSelect(mailbox string, id int) (int, *types.Mail, error) { +func (t *TableMails) MailCreate(accountPubKey string, mailbox string, data io.Reader, size int) (int, error) { + panic("sqlite3: MailCreate(stream) must not be called directly. Use LocalStorage wrapper.") +} + +// MailSelect retrieves mail metadata. +// Note: The Body field in types.Mail will be populated by the storage coordinator. +func (t *TableMails) MailSelect(accountPubKey string, mailbox string, id int) (int, *types.Mail, error) { var seq int var datetime int64 mail := &types.Mail{} - err := t.selectMail.QueryRow(mailbox, id).Scan( - &seq, &mail.ID, &mail.Mail, &datetime, + err := t.selectMail.QueryRow(accountPubKey, mailbox, id).Scan( + &seq, &mail.ID, &mail.RawData, &mail.MailFile, &mail.Size, &datetime, &mail.Seen, &mail.Answered, &mail.Flagged, &mail.Deleted, ) + if err != nil { + return 0, nil, err + } mail.Date = time.Unix(datetime, 0) - return seq, mail, err + return seq, mail, nil } -func (t *TableMails) MailSearch(mailbox string) ([]uint32, error) { +func (t *TableMails) MailSearch(accountPubKey string, mailbox string) ([]uint32, error) { var ids []uint32 - rows, err := t.searchMail.Query(mailbox) + rows, err := t.searchMail.Query(accountPubKey, mailbox) if err != nil { return nil, fmt.Errorf("t.searchMail.Query: %w", err) } - defer rows.Close() // nolint:errcheck + defer rows.Close() for rows.Next() { var id uint32 if err := rows.Scan(&id); err != nil { @@ -211,54 +244,59 @@ func (t *TableMails) MailSearch(mailbox string) ([]uint32, error) { return ids, nil } -func (t *TableMails) MailNextID(mailbox string) (int, error) { +func (t *TableMails) MailNextID(accountPubKey string, mailbox string) (int, error) { var id int - err := t.selectMailNextID.QueryRow(mailbox).Scan(&id) + err := t.selectMailNextID.QueryRow(accountPubKey, mailbox).Scan(&id) return id, err } -func (t *TableMails) MailIDForSeq(mailbox string, seq int) (int, error) { +func (t *TableMails) MailIDForSeq(accountPubKey string, mailbox string, seq int) (int, error) { var id int - err := t.selectIDForSeq.QueryRow(mailbox, seq).Scan(&id) + err := t.selectIDForSeq.QueryRow(accountPubKey, mailbox, seq).Scan(&id) return id, err } -func (t *TableMails) MailUnseen(mailbox string) (int, error) { +func (t *TableMails) MailUnseen(accountPubKey string, mailbox string) (int, error) { var unseen int - err := t.countUnseenMails.QueryRow(mailbox).Scan(&unseen) + err := t.countUnseenMails.QueryRow(accountPubKey, mailbox).Scan(&unseen) return unseen, err } -func (t *TableMails) MailUpdateFlags(mailbox string, id int, seen, answered, flagged, deleted bool) error { +func (t *TableMails) MailUpdateFlags(accountPubKey string, mailbox string, id int, seen, answered, flagged, deleted bool) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.updateMailFlags.Exec(seen, answered, flagged, deleted, mailbox, id) + _, err := t.updateMailFlags.Exec(seen, answered, flagged, deleted, accountPubKey, mailbox, id) return err }) } -func (t *TableMails) MailDelete(mailbox string, id int) error { +func (t *TableMails) MailDelete(accountPubKey string, mailbox string, id int) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.deleteMail.Exec(mailbox, id) + _, err := t.deleteMail.Exec(accountPubKey, mailbox, id) return err }) } -func (t *TableMails) MailExpunge(mailbox string) error { +func (t *TableMails) MailExpunge(accountPubKey string, mailbox string) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.expungeMail.Exec(mailbox) + _, err := t.expungeMail.Exec(accountPubKey, mailbox) return err }) } -func (t *TableMails) MailCount(mailbox string) (int, error) { +func (t *TableMails) MailCount(accountPubKey string, mailbox string) (int, error) { var count int - err := t.countMails.QueryRow(mailbox).Scan(&count) + err := t.countMails.QueryRow(accountPubKey, mailbox).Scan(&count) return count, err } -func (t *TableMails) MailMove(mailbox string, id int, destination string) error { - return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.moveMail.Exec(destination, mailbox, id) +func (t *TableMails) MailMove(accountPubKey string, mailbox string, id int, destination string) (int, error) { + var newID int + err := t.writer.Do(t.db, nil, func(txn *sql.Tx) error { + if err := t.selectMailNextID.QueryRow(accountPubKey, destination).Scan(&newID); err != nil { + return err + } + _, err := t.moveMail.Exec(accountPubKey, mailbox, destination, newID, id) return err }) + return newID, err } diff --git a/internal/storage/sqlite3/table_queue.go b/internal/storage/sqlite3/table_queue.go index 9441c49..f3800c4 100644 --- a/internal/storage/sqlite3/table_queue.go +++ b/internal/storage/sqlite3/table_queue.go @@ -1,5 +1,6 @@ /* - * Copyright (c) 2021 Neil Alexander + * Copyright (c) 2024 Your Name / Yggmail Contributor + * (Original Copyright (c) 2021 Neil Alexander) * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -27,13 +28,14 @@ type TableQueue struct { const queueSchema = ` CREATE TABLE IF NOT EXISTS queue ( - destination TEXT NOT NULL, - mailbox TEXT NOT NULL, - id INTEGER NOT NULL, - mail TEXT NOT NULL, - rcpt TEXT NOT NULL, - PRIMARY KEY (destination, mailbox, id), - FOREIGN KEY (mailbox, id) REFERENCES mails(mailbox, id) ON DELETE CASCADE ON UPDATE CASCADE + account_pubkey TEXT NOT NULL, + destination TEXT NOT NULL, + mailbox TEXT NOT NULL DEFAULT 'Outbox', + id INTEGER NOT NULL, + mail TEXT NOT NULL, -- Это поле From в оригинале + rcpt TEXT NOT NULL, + PRIMARY KEY (account_pubkey, destination, mailbox, id), + FOREIGN KEY (account_pubkey, mailbox, id) REFERENCES mails(account_pubkey, mailbox, id) ON DELETE CASCADE ON UPDATE CASCADE ); ` @@ -42,20 +44,20 @@ const queueSelectDestinationsStmt = ` ` const queueSelectIDsForDestinationStmt = ` - SELECT id, mail, rcpt FROM queue WHERE destination = $1 + SELECT account_pubkey, id, mail, rcpt FROM queue WHERE destination = $1 ORDER BY id DESC ` const queueInsertDestinationForIDStmt = ` - INSERT INTO queue (destination, mailbox, id, mail, rcpt) VALUES($1, $2, $3, $4, $5) + INSERT INTO queue (account_pubkey, destination, mailbox, id, mail, rcpt) VALUES($1, $2, $3, $4, $5, $6) ` const deleteDestinationForIDStmt = ` - DELETE FROM queue WHERE destination = $1 AND mailbox = $2 AND id = $3 + DELETE FROM queue WHERE account_pubkey = $1 AND destination = $2 AND mailbox = $3 AND id = $4 ` const queueSelectIsMessagePendingSendStmt = ` - SELECT COUNT(*) FROM queue WHERE mailbox = $1 AND id = $2 + SELECT COUNT(*) FROM queue WHERE account_pubkey = $1 AND mailbox = $2 AND id = $3 ` func NewTableQueue(db *sql.DB, writer *Writer) (*TableQueue, error) { @@ -98,7 +100,7 @@ func (t *TableQueue) QueueListDestinations() ([]string, error) { } return nil, fmt.Errorf("t.queueSelectDestinations.Query: %w", err) } - defer rows.Close() // nolint:errcheck + defer rows.Close() var destinations []string for rows.Next() { var destination string @@ -116,48 +118,49 @@ func (t *TableQueue) QueueMailIDsForDestination(destination string) ([]types.Que if err == sql.ErrNoRows { return nil, nil } - return nil, fmt.Errorf("t.queueSelectDestinations.Query: %w", err) + return nil, fmt.Errorf("t.queueSelectIDsForDestination.Query: %w", err) } - defer rows.Close() // nolint:errcheck + defer rows.Close() + var ids []types.QueuedMail for rows.Next() { + var aPk string var id int var from, rcpt string - if err := rows.Scan(&id, &from, &rcpt); err != nil { + if err := rows.Scan(&aPk, &id, &from, &rcpt); err != nil { return nil, fmt.Errorf("rows.Scan: %w", err) } ids = append(ids, types.QueuedMail{ - ID: id, - From: from, - Rcpt: rcpt, + AccountPubKey: aPk, + ID: id, + From: from, + Rcpt: rcpt, }) } return ids, nil } -func (t *TableQueue) QueueInsertDestinationForID(destination string, id int, from, rcpt string) error { +func (t *TableQueue) QueueInsertDestinationForID(pk string, destination string, id int, from, rcpt string) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.queueInsertDestinationForID.Exec(destination, "Outbox", id, from, rcpt) + _, err := t.queueInsertDestinationForID.Exec(pk, destination, "Outbox", id, from, rcpt) return err }) } -func (t *TableQueue) QueueDeleteDestinationForID(destination string, id int) error { +func (t *TableQueue) QueueDeleteDestinationForID(pk string, destination string, id int) error { return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { - _, err := t.queueDeleteIDForDestination.Exec(destination, "Outbox", id) + _, err := t.queueDeleteIDForDestination.Exec(pk, destination, "Outbox", id) return err }) } -func (t *TableQueue) QueueSelectIsMessagePendingSend(mailbox string, id int) (bool, error) { - row := t.queueSelectIsMessagePendingSend.QueryRow(mailbox, id) - 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 - } +func (t *TableQueue) QueueSelectIsMessagePendingSend(pk string, mailbox string, id int) (bool, error) { + row := t.queueSelectIsMessagePendingSend.QueryRow(pk, mailbox, id) var count int if err := row.Scan(&count); err != nil { + if err == sql.ErrNoRows { + return false, nil + } return false, fmt.Errorf("row.Scan: %w", err) } return count > 0, nil diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1441265..1c1bd2e 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,14 +1,30 @@ /* - * Copyright (c) 2021 Neil Alexander + * (Original Copyright (c) 2021 Neil Alexander) * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package storage -import "github.com/neilalexander/yggmail/internal/storage/types" +import ( + "io" + + "github.com/neilalexander/yggmail/internal/storage/types" +) + +type BlobStore interface { + StoreMail(aPk string, mailbox string, reader io.Reader) (string, int64, error) + ReadMail(location string) (io.ReadCloser, error) + DeleteMail(location string) error + MoveMail(oldLocation string, newMailbox string) (string, error) +} + +type MailStore interface { + Storage + MailCreateRaw(pk string, mailbox string, data io.Reader, location string, size int64) (int, error) +} type Storage interface { ConfigGet(key string) (string, error) @@ -16,28 +32,33 @@ type Storage interface { ConfigSetPassword(password string) error ConfigTryPassword(password string) (bool, error) - MailboxSelect(mailbox string) (bool, error) - MailNextID(mailbox string) (int, error) - MailIDForSeq(mailbox string, id int) (int, error) - MailUnseen(mailbox string) (int, error) - MailboxList(onlySubscribed bool) ([]string, error) - MailboxCreate(name string) error - MailboxRename(old, new string) error - MailboxDelete(name string) error - MailboxSubscribe(name string, subscribed bool) error + AccountGet(pk string) (*types.Account, error) + AccountCreate(acc *types.Account) error + AccountDelete(pk string) error + AccountList() ([]*types.Account, error) - MailCreate(mailbox string, data []byte) (int, error) - MailSelect(mailbox string, id int) (int, *types.Mail, error) - MailSearch(mailbox string) ([]uint32, error) - MailUpdateFlags(mailbox string, id int, seen, answered, flagged, deleted bool) error - MailDelete(mailbox string, id int) error - MailExpunge(mailbox string) error - MailCount(mailbox string) (int, error) - MailMove(mailbox string, id int, destination string) error + MailboxSelect(pk string, mailbox string) (bool, error) + MailNextID(pk string, mailbox string) (int, error) + MailIDForSeq(pk string, mailbox string, seq int) (int, error) + MailUnseen(pk string, mailbox string) (int, error) + MailboxList(pk string, onlySubscribed bool) ([]string, error) + MailboxCreate(pk string, name string) error + MailboxRename(pk string, old, new string) error + MailboxDelete(pk string, name string) error + MailboxSubscribe(pk string, name string, subscribed bool) error + + MailCreate(pk string, mailbox string, r io.Reader, size int) (int, error) + MailSelect(pk string, mailbox string, id int) (int, *types.Mail, error) + MailSearch(pk string, mailbox string) ([]uint32, error) + MailUpdateFlags(pk string, mailbox string, id int, seen, answered, flagged, deleted bool) error + MailDelete(pk string, mailbox string, id int) error + MailExpunge(pk string, mailbox string) error + MailCount(pk string, mailbox string) (int, error) + MailMove(pk string, mailbox string, id int, destination string) (int, error) QueueListDestinations() ([]string, error) QueueMailIDsForDestination(destination string) ([]types.QueuedMail, error) - QueueInsertDestinationForID(destination string, id int, from, rcpt string) error - QueueDeleteDestinationForID(destination string, id int) error - QueueSelectIsMessagePendingSend(mailbox string, id int) (bool, error) + QueueInsertDestinationForID(pk string, destination string, id int, from, rcpt string) error + QueueDeleteDestinationForID(pk string, destination string, id int) error + QueueSelectIsMessagePendingSend(pk string, mailbox string, id int) (bool, error) } diff --git a/internal/storage/types/types.go b/internal/storage/types/types.go index 0eafdc3..000b09f 100644 --- a/internal/storage/types/types.go +++ b/internal/storage/types/types.go @@ -8,21 +8,57 @@ package types -import "time" +import ( + "io" + "time" +) type Mail struct { - Mailbox string ID int - Mail []byte + Body io.ReadCloser // Unified interface for content + MailFile string + Size uint32 Date time.Time Seen bool Answered bool Flagged bool Deleted bool + + RawData []byte // For small blobs +} + +func (m *Mail) Len() int { return int(m.Size) } + +func (m *Mail) Read(p []byte) (n int, err error) { + if m.Body == nil { + return 0, io.EOF + } + return m.Body.Read(p) +} + +func (m *Mail) Close() error { + if m.Body == nil { + return nil + } + return m.Body.Close() } type QueuedMail struct { - ID int - From string - Rcpt string + AccountPubKey string + ID int + From string + Rcpt string +} + +// Account is represintation of Account on Domain +// +// # Account can be added in host if added Pubkey of Account +// +// Work in client mode if we have Password and Privkey. +// Else we work in relay mode and wait client +type Account struct { + PublicKey string + Aliases string // In Future realise simple-name auth by username + PasswdHash string // Optional: For IMAP client + Privatekey string // Optional: Encrypted by Password for client mode } diff --git a/internal/utils/address.go b/internal/utils/address.go index 4815dd2..7fc9ff3 100644 --- a/internal/utils/address.go +++ b/internal/utils/address.go @@ -1,5 +1,6 @@ /* - * Copyright (c) 2021 Neil Alexander + * Copyright (c) 2026 Kaiy Ragur + * Original Copyright (c) 2021 Neil Alexander * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -10,33 +11,53 @@ package utils import ( "crypto/ed25519" - "encoding/hex" + "encoding/base32" "fmt" "strings" ) -const Domain = "yggmail" - -func CreateAddress(pk ed25519.PublicKey) string { +func CreateAddress(aPk, dPk ed25519.PublicKey) string { return fmt.Sprintf( "%s@%s", - pk, Domain, + EncodeString(aPk), EncodeString(dPk), ) } -func ParseAddress(email string) (ed25519.PublicKey, error) { - at := strings.LastIndex(email, "@") - if at == 0 { - return nil, fmt.Errorf("invalid email address") - } - if email[at+1:] != Domain { - return nil, fmt.Errorf("invalid email domain") - } - pk, err := hex.DecodeString(email[:at]) +func EncodeString(k []byte) string { + return strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(k)) +} + +func DecodeString(s string) ([]byte, error) { + pk, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(s)) if err != nil { return nil, fmt.Errorf("hex.DecodeString: %w", err) } - ed := make(ed25519.PublicKey, ed25519.PublicKeySize) - copy(ed, pk) - return ed, nil + if len(pk) == 0 { + return nil, fmt.Errorf("utils.DecodeStrings: len %d", len(pk)) + } + return pk, nil +} + +func ParseAddress(email string) (ed25519.PublicKey, ed25519.PublicKey, error) { + at := strings.LastIndex(email, "@") + if at == 0 { + return nil, nil, fmt.Errorf("invalid email address") + } + // Domain key + host := email[at+1:] + hostPk, err := DecodeString(host) + if err != nil { + return nil, nil, fmt.Errorf("invalid email domain: %w", err) + } else if len(hostPk) != ed25519.PublicKeySize { + return nil, nil, fmt.Errorf("invalid email domain: invalid keysize") + } + // Account key + user := email[:at] + userPk, err := DecodeString(user) + if err != nil { + return nil, nil, fmt.Errorf("invalid email account: %w", err) + } else if len(userPk) != ed25519.PublicKeySize { + return nil, nil, fmt.Errorf("invalid email account: invalid keysize") + } + return hostPk, userPk, nil } diff --git a/internal/utils/age/age.go b/internal/utils/age/age.go new file mode 100644 index 0000000..098d2a7 --- /dev/null +++ b/internal/utils/age/age.go @@ -0,0 +1,63 @@ +package age + +import ( + "crypto/ed25519" + "io" + + "filippo.io/age" + "filippo.io/age/armor" + "github.com/neilalexander/yggmail/internal/utils" + "github.com/neilalexander/yggmail/internal/utils/age/bech32" +) + +func Recipient(pub ...ed25519.PublicKey) (rcpt []age.Recipient, err error) { + for _, p := range pub { + x, _ := utils.PubEd25519ToX25519(p) + s, _ := bech32.Encode("age", x) + r, err := age.ParseX25519Recipient(s) + if err != nil { + return nil, err + } + rcpt = append(rcpt, r) + } + return rcpt, nil +} +func Identity(pub ed25519.PrivateKey) (age.Identity, error) { + p, _ := utils.PrivEd25519ToX25519(pub) + s, _ := bech32.Encode("AGE-SECRET-KEY-", p) + return age.ParseX25519Identity(s) +} + +type armorCloseWrapper struct { + io.WriteCloser // age writer + armorWriter io.WriteCloser +} + +func (w *armorCloseWrapper) Close() error { + if err := w.WriteCloser.Close(); err != nil { + return err + } + return w.armorWriter.Close() +} + +func EncryptArmor(w io.Writer, i ...age.Recipient) (io.WriteCloser, error) { + aw := armor.NewWriter(w) + ew, err := age.Encrypt(aw, i...) + if err != nil { + aw.Close() + return nil, err + } + return &armorCloseWrapper{ew, aw}, nil +} + +func DecryptArmor(r io.Reader, i ...age.Identity) (io.Reader, error) { + r = armor.NewReader(r) + return Decrypt(r, i...) +} + +func Encrypt(w io.Writer, i ...age.Recipient) (io.WriteCloser, error) { + return age.Encrypt(w, i...) +} +func Decrypt(r io.Reader, i ...age.Identity) (io.Reader, error) { + return age.Decrypt(r, i...) +} diff --git a/internal/utils/age/bech32/bech32.go b/internal/utils/age/bech32/bech32.go new file mode 100644 index 0000000..a5d8330 --- /dev/null +++ b/internal/utils/age/bech32/bech32.go @@ -0,0 +1,173 @@ +// Copyright (c) 2017 Takatoshi Nakagawa +// Copyright (c) 2019 The age Authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package bech32 is a modified version of the reference implementation of BIP173. +package bech32 + +import ( + "fmt" + "strings" +) + +var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} + +func polymod(values []byte) uint32 { + chk := uint32(1) + for _, v := range values { + top := chk >> 25 + chk = (chk & 0x1ffffff) << 5 + chk = chk ^ uint32(v) + for i := range 5 { + bit := top >> i & 1 + if bit == 1 { + chk ^= generator[i] + } + } + } + return chk +} + +func hrpExpand(hrp string) []byte { + h := []byte(strings.ToLower(hrp)) + var ret []byte + for _, c := range h { + ret = append(ret, c>>5) + } + ret = append(ret, 0) + for _, c := range h { + ret = append(ret, c&31) + } + return ret +} + +func verifyChecksum(hrp string, data []byte) bool { + return polymod(append(hrpExpand(hrp), data...)) == 1 +} + +func createChecksum(hrp string, data []byte) []byte { + values := append(hrpExpand(hrp), data...) + values = append(values, []byte{0, 0, 0, 0, 0, 0}...) + mod := polymod(values) ^ 1 + ret := make([]byte, 6) + for p := range ret { + shift := 5 * (5 - p) + ret[p] = byte(mod>>shift) & 31 + } + return ret +} + +func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) { + var ret []byte + acc := uint32(0) + bits := byte(0) + maxv := byte(1<>frombits != 0 { + return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) + } + acc = acc<= tobits { + bits -= tobits + ret = append(ret, byte(acc>>bits)&maxv) + } + } + if pad { + if bits > 0 { + ret = append(ret, byte(acc<<(tobits-bits))&maxv) + } + } else if bits >= frombits { + return nil, fmt.Errorf("illegal zero padding") + } else if byte(acc<<(tobits-bits))&maxv != 0 { + return nil, fmt.Errorf("non-zero padding") + } + return ret, nil +} + +// Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase, +// the output will be uppercase. +func Encode(hrp string, data []byte) (string, error) { + values, err := convertBits(data, 8, 5, true) + if err != nil { + return "", err + } + if len(hrp) < 1 { + return "", fmt.Errorf("invalid HRP: %q", hrp) + } + for p, c := range hrp { + if c < 33 || c > 126 { + return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c) + } + } + if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp { + return "", fmt.Errorf("mixed case HRP: %q", hrp) + } + lower := strings.ToLower(hrp) == hrp + hrp = strings.ToLower(hrp) + var ret strings.Builder + ret.WriteString(hrp) + ret.WriteString("1") + for _, p := range values { + ret.WriteByte(charset[p]) + } + for _, p := range createChecksum(hrp, values) { + ret.WriteByte(charset[p]) + } + if lower { + return ret.String(), nil + } + return strings.ToUpper(ret.String()), nil +} + +// Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase. +func Decode(s string) (hrp string, data []byte, err error) { + if strings.ToLower(s) != s && strings.ToUpper(s) != s { + return "", nil, fmt.Errorf("mixed case") + } + pos := strings.LastIndex(s, "1") + if pos < 1 || pos+7 > len(s) { + return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s)) + } + hrp = s[:pos] + for p, c := range hrp { + if c < 33 || c > 126 { + return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c) + } + } + s = strings.ToLower(s) + for p, c := range s[pos+1:] { + d := strings.IndexRune(charset, c) + if d == -1 { + return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c) + } + data = append(data, byte(d)) + } + if !verifyChecksum(hrp, data) { + return "", nil, fmt.Errorf("invalid checksum") + } + data, err = convertBits(data[:len(data)-6], 5, 8, false) + if err != nil { + return "", nil, err + } + return hrp, data, nil +} diff --git a/internal/utils/crypt.go b/internal/utils/crypt.go new file mode 100644 index 0000000..18ec080 --- /dev/null +++ b/internal/utils/crypt.go @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + + "golang.org/x/crypto/scrypt" +) + +// Get base64 string of encrpted data +// salt [:16] +// nonce [16:28] +// data [28:] +func AESEncrypt(rawKey string, password string) (string, error) { + salt := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", err + } + + key, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nil, nonce, []byte(rawKey), nil) + + result := append(salt, append(nonce, ciphertext...)...) + return base64.StdEncoding.EncodeToString(result), nil +} + +func AESDecrypt(encryptedB64 string, password string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encryptedB64) + if err != nil { + return "", err + } + + if len(data) < 16+12 { // Salt(16) + Nonce(12) + return "", fmt.Errorf("invalid encrypted data") + } + + salt := data[:16] + nonce := data[16:28] + ciphertext := data[28:] + + key, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + rawKey, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decryption failed (wrong password?): %w", err) + } + + return string(rawKey), nil +} diff --git a/internal/utils/e2ee/crypt.go b/internal/utils/e2ee/crypt.go new file mode 100644 index 0000000..c6dc832 --- /dev/null +++ b/internal/utils/e2ee/crypt.go @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package e2ee + +import ( + "crypto/ed25519" + "crypto/sha512" + "io" + "os" +) + +type Encryptor func(io.Writer, ...ed25519.PublicKey) (io.WriteCloser, error) +type Decryptor func(io.Reader) (io.Reader, error) +type Verifier func([]byte, []byte) error +type Signer func([]byte) ([]byte, error) + +func EncryptPack(input io.ReadSeeker, rcpt []ed25519.PublicKey, signer Signer, enc Encryptor) (io.Reader, error) { + input.Seek(0, io.SeekStart) + + h := sha512.New() + if _, err := io.Copy(h, input); err != nil { + return nil, err + } + + sig, err := signer(h.Sum(nil)) + if err != nil { + return nil, err + } + + pr, pw := io.Pipe() + go func() { + input.Seek(0, io.SeekStart) + ew, _ := enc(pw, rcpt...) + ew.Write(sig) + io.Copy(ew, input) + ew.Close() + pw.Close() + }() + return pr, nil +} + +func DecryptPack(input io.Reader, dec Decryptor, verifier Verifier) (io.ReadCloser, error) { + dr, err := dec(input) + if err != nil { + return nil, err + } + + sig := make([]byte, 64) + if _, err := io.ReadFull(dr, sig); err != nil { + return nil, err + } + + tmp, _ := os.CreateTemp("", "ygg-mail-*") + h := sha512.New() + + io.Copy(io.MultiWriter(tmp, h), dr) + + if err := verifier(h.Sum(nil), sig); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return nil, err + } + + tmp.Seek(0, io.SeekStart) + return &tempFileCloser{File: tmp}, nil +} + +type tempFileCloser struct { + *os.File +} + +func (t *tempFileCloser) Close() error { + t.File.Close() + return os.Remove(t.Name()) +} diff --git a/internal/utils/e2ee/e2ee_test.go b/internal/utils/e2ee/e2ee_test.go new file mode 100644 index 0000000..a4f582c --- /dev/null +++ b/internal/utils/e2ee/e2ee_test.go @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package e2ee_test + +import ( + "bytes" + "crypto/ed25519" + "io" + "testing" + + "github.com/neilalexander/yggmail/internal/utils" + "github.com/neilalexander/yggmail/internal/utils/age" + "github.com/neilalexander/yggmail/internal/utils/e2ee" +) + +func TestFullE2EECycle(t *testing.T) { + _, senderPriv, _ := ed25519.GenerateKey(nil) + senderPub := senderPriv.Public().(ed25519.PublicKey) + _, recipientPriv, _ := ed25519.GenerateKey(nil) + recipientPub := recipientPriv.Public().(ed25519.PublicKey) + + _, domainPriv, _ := ed25519.GenerateKey(nil) + domainPub := domainPriv.Public().(ed25519.PublicKey) + + senderAddr := utils.CreateAddress(senderPub, domainPub) // например: userkey@domainkey + + original := []byte("From: " + senderAddr + "\r\nSubject: test\r\n\r\nHello world! Это тест.") + + encryptedReader, err := e2ee.EncryptFilter( + bytes.NewReader(original), + senderAddr, + []ed25519.PublicKey{recipientPub}, + func(data []byte) ([]byte, error) { + return ed25519.Sign(senderPriv, data), nil + }, + func(w io.Writer, rcpt ...ed25519.PublicKey) (io.WriteCloser, error) { + r, err := age.Recipient(rcpt...) + if err != nil { + return nil, err + } + return age.EncryptArmor(w, r...) + }, + ) + if err != nil { + t.Fatal("EncryptFilter failed:", err) + } + + encryptedBytes, _ := io.ReadAll(encryptedReader) + encryptedReader.Close() + + stored := bytes.NewReader(encryptedBytes) + + decryptor := func(r io.Reader) (io.Reader, error) { + id, err := age.Identity(recipientPriv) + if err != nil { + return nil, err + } + return age.DecryptArmor(r, id) + } + + decrypted, err := e2ee.DecryptFilter(stored, decryptor) + if err != nil { + t.Fatal("DecryptFilter failed:", err) + } + + result, _ := io.ReadAll(decrypted) + decrypted.Close() + + if !bytes.Contains(result, []byte("Hello world! Это тест.")) { + t.Fatalf("Decryption failed, got:\n%s", string(result)) + } + + if !bytes.Contains(result, []byte("Subject: test")) { + t.Fatalf("Original headers lost, got:\n%s", string(result)) + } + + t.Log("Full E2EE cycle OK. Decrypted body preview:\n", string(result[:200])) +} diff --git a/internal/utils/e2ee/msg.go b/internal/utils/e2ee/msg.go new file mode 100644 index 0000000..565d077 --- /dev/null +++ b/internal/utils/e2ee/msg.go @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package e2ee + +import ( + "crypto/ed25519" + "fmt" + "io" + "net/mail" + "time" + + "github.com/emersion/go-message" + "github.com/neilalexander/yggmail/internal/utils" +) + +func EncryptFilter( + origMsg io.ReadSeeker, + fromAddr string, + rcptPk []ed25519.PublicKey, + mySign Signer, + myEnc Encryptor, +) (io.ReadCloser, error) { + packReader, err := EncryptPack(origMsg, rcptPk, mySign, myEnc) + if err != nil { + return nil, fmt.Errorf("failed to sign and encrypt: %w", err) + } + + hdr := new(message.Header) + hdr.Set("From", fromAddr) + hdr.Set("Date", time.Now().Format(time.RFC1123Z)) + hdr.Set("X-Yggmail-Encryption", "v1") + + pr, pw := io.Pipe() + go func() { + tw, err := message.CreateWriter(pw, *hdr) + if err != nil { + pw.CloseWithError(err) + return + } + + _, err = io.Copy(tw, packReader) + tw.Close() + pw.CloseWithError(err) + }() + + return pr, nil +} + +func DecryptFilter(r io.ReadSeeker, decryptor Decryptor) (io.ReadCloser, error) { + r.Seek(0, io.SeekStart) + + msg, err := mail.ReadMessage(r) + if err != nil { + r.Seek(0, io.SeekStart) + return io.NopCloser(r), nil + } + + if msg.Header.Get("X-Yggmail-Encryption") != "v1" { + r.Seek(0, io.SeekStart) + return io.NopCloser(r), nil + } + + fromStr := msg.Header.Get("From") + fPk := extractPublicKey(fromStr) + if fPk == nil { + r.Seek(0, io.SeekStart) + return io.NopCloser(r), nil + } + + innerVerify := func(data []byte, sig []byte) error { + if !ed25519.Verify(fPk, data, sig) { + return fmt.Errorf("inner signature invalid") + } + return nil + } + + decrypted, err := DecryptPack(msg.Body, decryptor, innerVerify) + if err != nil { + r.Seek(0, io.SeekStart) + return nil, fmt.Errorf("decrypt enc packet error: %w", err) + } + + return decrypted, nil +} +func extractPublicKey(addr string) ed25519.PublicKey { + a, err := mail.ParseAddress(addr) + if err != nil { + return nil + } + addr = a.Address + _, aPk, err := utils.ParseAddress(addr) + if err != nil { + return nil + } + return aPk +} diff --git a/internal/utils/x25519.go b/internal/utils/x25519.go new file mode 100644 index 0000000..151bd03 --- /dev/null +++ b/internal/utils/x25519.go @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Kaiy Ragur + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package utils + +import ( + "crypto/ed25519" + "crypto/sha512" + + "filippo.io/edwards25519" +) + +func PubEd25519ToX25519(pub ed25519.PublicKey) ([]byte, error) { + point, err := edwards25519.NewIdentityPoint().SetBytes(pub) + if err != nil { + return nil, err + } + x25519Pub := point.BytesMontgomery() + + return x25519Pub, nil +} + +func PrivEd25519ToX25519(priv ed25519.PrivateKey) ([]byte, error) { + seed := priv.Seed() + h := sha512.Sum512(seed) + x25519Priv := h[:32] + return x25519Priv, nil +} diff --git a/internal/welcome/welcome.go b/internal/welcome/welcome.go index c8fef60..da22896 100644 --- a/internal/welcome/welcome.go +++ b/internal/welcome/welcome.go @@ -3,9 +3,10 @@ package welcome import ( "bytes" "fmt" + "log" + "github.com/emersion/go-message" "github.com/neilalexander/yggmail/internal/storage" - "log" ) const ( @@ -27,14 +28,14 @@ func Onboard(user string, storage storage.Storage, log *log.Logger) { log.Println("Failure to generate welcome message") } var welcomeId int - if id, e := storage.MailCreate("INBOX", welcomeMsg); e != nil { + if id, e := storage.MailCreate(user, "INBOX", bytes.NewBuffer(welcomeMsg), len(welcomeMsg)); e != nil { log.Printf("Failed to store welcome message: %v\n", e) panic("See above") } else { welcomeId = id } - if storage.MailUpdateFlags("INBOX", welcomeId, false, false, false, false) != nil { + if storage.MailUpdateFlags(user, "INBOX", welcomeId, false, false, false, false) != nil { panic("Could not set flags on onboarding message") }