- Inter-node Protocol: Implemented a custom communication protocol based on Protobuf structures.

- Bridge Node Support: Added functionality to manage mail (send/receive) on remote nodes via a bridge node. Full transparent management requires a private key on the bridge, while the remote node can operate with only a public key.
 - End-to-End Encryption (E2EE): Integrated E2EE using age. Encryption and decryption occur strictly at the bridge level; node administrators cannot access message content.
 - New Addressing Schema: Transitioned from nodepub@yggmail to userpub@nodepub.
 - Key Adaptation: Implemented ed25519 to x25519 coordinate adaptation, allowing signing keys to be used for encryption.

Changed
 - Address Encoding: Switched from hex16 to base32 for the address space.
 - Memory Optimization: Replaced direct memory allocations with streaming for mail processing to reduce RAM overhead.
 - Code Refactoring: Performed decomposition of the original inherited codebase to improve maintainability.

Fixed
 - Mail Updates: Fixed the mail retrieval mechanism to ensure near-instant updates for incoming messages.

Changes to be committed:
	modified:   .gitignore
	modified:   README.md
	modified:   cmd/yggmail/main.go
	modified:   go.mod
	modified:   go.sum
	new file:   internal/account/manager.go
	new file:   internal/account/session.go
	modified:   internal/imapserver/backend.go
	modified:   internal/imapserver/imap.go
	modified:   internal/imapserver/mailbox.go
	modified:   internal/imapserver/notify.go
	modified:   internal/imapserver/user.go
	new file:   internal/notify/notify.go
	new file:   internal/notify/remote/remote.go
	new file:   internal/remote/client.go
	new file:   internal/remote/handlers.go
	new file:   internal/remote/io.go
	new file:   internal/remote/server.go
	new file:   internal/remote/sign.go
	new file:   internal/remote/types/request.pb.go
	new file:   internal/remote/types/request.proto
	new file:   internal/remote/types/response.pb.go
	new file:   internal/remote/types/response.proto
	new file:   internal/shell/shell.go
	modified:   internal/smtpsender/sender.go
	modified:   internal/smtpserver/backend.go
	modified:   internal/smtpserver/session_local.go
	modified:   internal/smtpserver/session_remote.go
	modified:   internal/smtpserver/smtp.go
	new file:   internal/storage/filestore/filestore.go
	new file:   internal/storage/local/storage.go
	new file:   internal/storage/remote/mail.go
	new file:   internal/storage/remote/mailbox.go
	new file:   internal/storage/remote/queue.go
	new file:   internal/storage/remote/storage.go
	new file:   internal/storage/remote/watch.go
	modified:   internal/storage/sqlite3/sqlite3.go
	new file:   internal/storage/sqlite3/table_accounts.go
	modified:   internal/storage/sqlite3/table_mailboxes.go
	modified:   internal/storage/sqlite3/table_mails.go
	modified:   internal/storage/sqlite3/table_queue.go
	modified:   internal/storage/storage.go
	modified:   internal/storage/types/types.go
	modified:   internal/utils/address.go
	new file:   internal/utils/age/age.go
	new file:   internal/utils/age/bech32/bech32.go
	new file:   internal/utils/crypt.go
	new file:   internal/utils/e2ee/crypt.go
	new file:   internal/utils/e2ee/e2ee_test.go
	new file:   internal/utils/e2ee/msg.go
	new file:   internal/utils/x25519.go
	modified:   internal/welcome/welcome.go
This commit is contained in:
Kaiy Belist 2026-02-14 02:34:09 +05:00
parent 8bf3ba5f47
commit 39759db7c2
52 changed files with 6973 additions and 1069 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
*.db *.db
.DS_Store .DS_Store
test

131
README.md
View File

@ -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; * **New Addressing Scheme:** Migrated from `hex16` to **base32**. Addresses now follow the format: `<userpub>@<nodepub>`.
* Implements IMAP and SMTP protocols for sending and receiving mail, so you can use your favourite client (hopefully); * **Inter-node Protocol:** Implemented a custom node-to-node communication protocol using **Protobuf** structures.
* Mails are exchanged between Yggmail users using built-in Yggdrasil connectivity; * **Bridge Architecture:** * Nodes can now act as bridges. You can connect to a remote node from a bridge to fetch or send mail transparently.
* All mail exchange traffic between any two Yggmail nodes is always end-to-end encrypted without exception; * Private keys are only required on the bridge; the end-node can operate with public keys only, simplifying administration.
* Yggdrasil and Yggmail nodes on the same network are discovered automatically using multicast or you can configure a static Yggdrasil peer.
Email addresses are based on your public key, like `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 ## Quickstart
Use a recent version of Go to install Yggmail: ### 1. Installation
``` Install the binary using Go:
go install github.com/neilalexander/yggmail/cmd/yggmail@latest
```
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 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 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 -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 Connect your favorite mail client to the following local endpoints:
* IMAP is listening on `localhost` port 1143, username is your mail address, plain password authentication, no SSL/TLS
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 ## 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: * **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.).
* Yggmail needs to be running in order to receive inbound emails — it's therefore important to run Yggmail somewhere that will have good uptime; * **Client Compatibility:** You may need to enable "Allow insecure authentication" in your mail client since the local listeners do not use SSL/TLS yet.
* Yggmail tries to guarantee that senders are who they say they are. Your `From` address must be your Yggmail address; * **Size Limit:** Emails are currently capped at default **10mb** max **4GB**.
* 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.

View File

@ -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 * 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 * License, v. 2.0. If a copy of the MPL was not distributed with this
@ -9,9 +10,7 @@
package main package main
import ( import (
"bytes"
"crypto/ed25519" "crypto/ed25519"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@ -23,32 +22,41 @@ import (
"github.com/emersion/go-sasl" "github.com/emersion/go-sasl"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/fatih/color" "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/config"
"github.com/neilalexander/yggmail/internal/imapserver" "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/smtpsender"
"github.com/neilalexander/yggmail/internal/smtpserver" "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/storage/sqlite3"
"github.com/neilalexander/yggmail/internal/transport" "github.com/neilalexander/yggmail/internal/transport"
"github.com/neilalexander/yggmail/internal/utils" "github.com/neilalexander/yggmail/internal/utils"
"github.com/neilalexander/yggmail/internal/welcome" "github.com/neilalexander/yggmail/internal/welcome"
"golang.org/x/crypto/bcrypt"
) )
type peerAddrList []string type peerAddrList []string
func (i *peerAddrList) String() string { func (i *peerAddrList) String() string { return strings.Join(*i, ", ") }
return strings.Join(*i, ", ")
}
func (i *peerAddrList) Set(value string) error { func (i *peerAddrList) Set(value string) error {
*i = append(*i, value) *i = append(*i, value)
return nil return nil
} }
func main() { func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
rawlog := log.New(color.Output, "", 0) rawlog := log.New(color.Output, "", 0)
green := color.New(color.FgGreen).SprintfFunc() green := color.New(color.FgGreen).SprintfFunc()
log := log.New(rawlog.Writer(), fmt.Sprintf("[ %s ] ", green("Yggmail")), log.LstdFlags|log.Lmsgprefix) 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") imapaddr := flag.String("imap", "localhost:1143", "IMAP listen address")
multicast := flag.Bool("multicast", false, "Connect to Yggdrasil peers on your LAN") multicast := flag.Bool("multicast", false, "Connect to Yggdrasil peers on your LAN")
mcastregexp := flag.String("mcastregexp", ".*", "Regexp for multicast") mcastregexp := flag.String("mcastregexp", ".*", "Regexp for multicast")
password := flag.Bool("password", false, "Set a new IMAP/SMTP password") shellMode := flag.String("sh", "", "-sh \"command args\"")
passwordhash := flag.String("passwordhash", "", "Set a new IMAP/SMTP password (hash)") flag.Var(&peerAddrs, "peer", "Connect to a specific Yggdrasil static peer")
flag.Var(&peerAddrs, "peer", "Connect to a specific Yggdrasil static peer (this option can be given more than once)")
flag.Parse() flag.Parse()
if flag.NFlag() == 0 { db, err := sqlite3.NewSQLite3StorageStorage(*database)
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)
if err != nil { if err != nil {
panic(err) log.Fatalf("Failed to open database: %v", err)
} }
defer storage.Close() // nolint:errcheck defer db.Close()
log.Printf("Using database file %q\n", *database)
skStr, err := storage.ConfigGet("private_key") fs, err := filestore.NewFileStore("./mails/")
if err != nil { if err != nil {
panic(err) log.Fatalf("Failed to open filesystem: %v", err)
} }
sk := make(ed25519.PrivateKey, ed25519.PrivateKeySize) storage := local.NewLocalStorage(db, fs, 0)
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)
for _, name := range []string{"INBOX", "Outbox", "Sent"} { accManager := account.NewManager(storage, log)
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)
}
nodeSk, nodePk := getID(storage, log)
cfg := &config.Config{ cfg := &config.Config{
PublicKey: pk, PublicKey: nodePk,
PrivateKey: sk, 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 { if err != nil {
panic(err) log.Fatalf("Failed to setup Yggdrasil: %v", err)
} }
queues := smtpsender.NewQueues(cfg, log, transport, storage) // Notify bus
var notify *imapserver.IMAPNotify notify := notify.NewNotifyBus(log)
imapBackend := &imapserver.Backend{ // Imap Frontend
Log: log, imapBackend := imapserver.NewBackend(
Config: cfg, cfg,
Storage: storage, log,
} storage,
accManager,
tr,
notify,
)
_, notify, err = imapserver.NewIMAPServer(imapBackend, *imapaddr, true) srv, err := imapserver.NewIMAPServer(imapBackend, *imapaddr, true)
if err != nil { if err != nil {
log.Fatal(err) 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() { go func() {
localBackend := &smtpserver.Backend{ localBackend := &smtpserver.Backend{
Log: log, Log: log,
Mode: smtpserver.BackendModeInternal, Mode: smtpserver.BackendModeInternal,
Config: cfg, // Config: cfg,
Storage: storage, Storage: storage,
Queues: queues, Queues: queues,
Notify: notify, Notify: notify,
Account: accManager,
NodePublicKey: cfg.PublicKey,
} }
localServer := smtp.NewServer(localBackend) server := smtp.NewServer(localBackend)
localServer.Addr = *smtpaddr server.Addr = *smtpaddr
localServer.Domain = hex.EncodeToString(pk) server.Domain = utils.EncodeString(nodePk)
localServer.MaxMessageBytes = 1024 * 1024 * 32 server.AllowInsecureAuth = true
localServer.MaxRecipients = 50 server.EnableAuth(sasl.Login, func(conn *smtp.Conn) sasl.Server {
localServer.AllowInsecureAuth = true
localServer.EnableAuth(sasl.Login, func(conn *smtp.Conn) sasl.Server {
return sasl.NewLoginServer(func(username, password string) error { return sasl.NewLoginServer(func(username, password string) error {
_, err := localBackend.Login(nil, username, password) _, err := localBackend.Login(nil, username, password)
return err return err
}) })
}) })
log.Println("Listening for local SMTP on:", server.Addr)
log.Println("Listening for SMTP on:", localServer.Addr) if err := server.ListenAndServe(); err != nil {
if err := localServer.ListenAndServe(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
}() }()
// RPC Mail-Server
remoteSrv := remote.NewRemoteServer(cfg.PublicKey, storage, accManager, log, notify, queues)
go func() { go func() {
overlayBackend := &smtpserver.Backend{ if err := remoteSrv.Serve(tr.Listener()); err != nil {
Log: log, log.Fatalf("Remote Storage Server failed: %v", err)
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)
} }
}() }()
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs <-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("--------------------------------------")
} }

4
go.mod
View File

@ -3,6 +3,7 @@ module github.com/neilalexander/yggmail
go 1.24.0 go 1.24.0
require ( require (
filippo.io/edwards25519 v1.1.0
github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap v1.2.1
github.com/emersion/go-imap-idle v0.0.0-20210907174914-db2568431445 github.com/emersion/go-imap-idle v0.0.0-20210907174914-db2568431445
github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872 github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872
@ -17,9 +18,12 @@ require (
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0 golang.org/x/term v0.37.0
google.golang.org/protobuf v1.36.11
) )
require ( 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/ironwood v0.0.0-20251124020000-e1358f790504 // indirect
github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d // indirect github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect

10
go.sum
View File

@ -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 h1:yQNDl2lGRV61efEug+cTnLYs7qnhnCm1TM42wZ+9xtE=
github.com/Arceliar/ironwood v0.0.0-20251124020000-e1358f790504/go.mod h1:SrrElc3FFMpYCODSr11jWbLFeOM8WsY+DbDY/l2AXF0= 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= 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/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 h1:l781G4dE+pbigClDSDzSaaYKtiueHCILUa/qSDsmHAo=
github.com/gologme/log v1.3.0/go.mod h1:yKT+DvIPdDdDoPtqFrFxheooyVmoqi0BAsw+erN3wA4= 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 h1:ZHLiZ+HaGqPOtEe8T6qY8QHnoEsAeBv8wqxniQAp+CY=
github.com/hjson/hjson-go/v4 v4.5.0/go.mod h1:4zx6c7Y0vWcm8IRyVoQJUHAPJLXLvbG6X8nk1RLigSo= 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= 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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

192
internal/account/manager.go Normal file
View File

@ -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")
}

View File

@ -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
}

View File

@ -9,46 +9,89 @@
package imapserver package imapserver
import ( import (
"encoding/hex"
"fmt" "fmt"
"log" "log"
"github.com/emersion/go-imap" "github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/backend"
"github.com/neilalexander/yggmail/internal/account"
"github.com/neilalexander/yggmail/internal/config" "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"
"github.com/neilalexander/yggmail/internal/storage/remote"
"github.com/neilalexander/yggmail/internal/transport"
"github.com/neilalexander/yggmail/internal/utils" "github.com/neilalexander/yggmail/internal/utils"
) )
type Backend struct { type Backend struct {
Config *config.Config Config *config.Config
Log *log.Logger Log *log.Logger
Storage storage.Storage Storage storage.Storage
Server *IMAPServer 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) { 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 our username is email-like, then take just the localpart
if pk, err := utils.ParseAddress(username); err == nil { dPk, _, err := utils.ParseAddress(username)
if !pk.Equal(b.Config.PublicKey) { if err != nil {
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 ")
return nil, fmt.Errorf("failed to authenticate: wrong domain in username")
}
} }
username = hex.EncodeToString(b.Config.PublicKey)
if authed, err := b.Storage.ConfigTryPassword(password); err != nil { remoteDomain := !dPk.Equal(b.Config.PublicKey)
b.Log.Printf("Failed to authenticate IMAP user %q due to error: %s", username, err)
return nil, fmt.Errorf("failed to authenticate: %w", err) session, err := b.Account.Login(username, password)
} else if !authed { if err != nil {
b.Log.Printf("Failed to authenticate IMAP user %q\n", username) return nil, fmt.Errorf("IMAP: Error to authenticate: %w", err)
return nil, backend.ErrInvalidCredentials }
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{ user := &User{
backend: b, storage: storage,
username: username, username: session.Username(),
session: session,
conn: conn, conn: conn,
log: b.Log, log: b.Log,
} }
return user, nil return user, nil
} }

View File

@ -10,6 +10,8 @@ package imapserver
import ( import (
"log" "log"
"net"
"time"
idle "github.com/emersion/go-imap-idle" idle "github.com/emersion/go-imap-idle"
move "github.com/emersion/go-imap-move" move "github.com/emersion/go-imap-move"
@ -20,31 +22,61 @@ import (
type IMAPServer struct { type IMAPServer struct {
server *server.Server server *server.Server
backend *Backend 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{ s := &IMAPServer{
server: server.New(backend), server: server.New(backend),
backend: backend, backend: backend,
} }
s.notify = NewIMAPNotify(s.server, backend.Log)
s.server.Addr = addr s.server.Addr = addr
s.server.AllowInsecureAuth = insecure 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(idle.NewExtension())
s.server.Enable(move.NewExtension()) s.server.Enable(move.NewExtension())
// s.server.Enable(s.notify)
s.server.EnableAuth(sasl.Login, func(conn server.Conn) sasl.Server { s.server.EnableAuth(sasl.Login, func(conn server.Conn) sasl.Server {
return sasl.NewLoginServer(func(username, password string) error { return sasl.NewLoginServer(func(username, password string) error {
_, err := s.backend.Login(nil, username, password) _, err := s.backend.Login(nil, username, password)
return err return err
}) })
}) })
// go func() {
// if err := s.server.ListenAndServe(); err != nil {
// log.Fatal(err)
// }
// }()
go func() { 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) log.Fatal(err)
} }
}() }()
return s, s.notify, nil return s, nil
} }

View File

@ -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 * 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 * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
package imapserver package imapserver
import ( import (
"bufio" "bufio"
"bytes"
"fmt"
"io" "io"
"time" "time"
"github.com/emersion/go-imap" "github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend/backendutil" "github.com/emersion/go-imap/backend/backendutil"
"github.com/emersion/go-message/textproto" "github.com/emersion/go-message/textproto"
"github.com/neilalexander/yggmail/internal/storage"
"github.com/neilalexander/yggmail/internal/storage/types" "github.com/neilalexander/yggmail/internal/storage/types"
"github.com/neilalexander/yggmail/internal/utils/e2ee"
) )
type Mailbox struct { type Mailbox struct {
backend *Backend storage storage.Storage
name string name string
user *User 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) { func (mbox *Mailbox) getIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]int32, error) {
var ids []int32 var ids []int32
pk := mbox.user.username
for _, set := range seqSet.Set { for _, set := range seqSet.Set {
if set.Stop == 0 { stop := set.Stop
next, err := mbox.backend.Storage.MailNextID(mbox.name) if stop == 0 {
if err != nil { // In IMAP 0 = All
return nil, fmt.Errorf("mbox.backend.Storage.MailNextID: %w", err) next, _ := mbox.storage.MailNextID(pk, mbox.name)
} stop = uint32(next - 1)
set.Stop = uint32(next - 1)
} }
for i := set.Start; i <= set.Stop; i++ { for i := set.Start; i <= stop; i++ {
if !uid { if !uid {
pid, err := mbox.backend.Storage.MailIDForSeq(mbox.name, int(i)) // Convert uid by db
if err != nil { pid, err := mbox.storage.MailIDForSeq(pk, mbox.name, int(i))
return nil, fmt.Errorf("mbox.backend.Storage.MailIDForSeq: %w", err) if err == nil {
ids = append(ids, int32(pid))
} }
ids = append(ids, int32(pid))
} else { } else {
ids = append(ids, int32(i)) ids = append(ids, int32(i))
} }
@ -52,283 +313,31 @@ func (mbox *Mailbox) getIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]int32, e
return ids, nil 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 { 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 { func (mbox *Mailbox) Check() error { return nil }
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) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
return mbox.backend.Storage.MailSearch(mbox.name) return mbox.storage.MailSearch(mbox.user.username, 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
} }
func (mbox *Mailbox) Expunge() error { 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 { func (mbox *Mailbox) MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error {
if dest == "Outbox" { pk := mbox.user.username
return fmt.Errorf("can't copy into Outbox as it is a protected folder")
}
ids, err := mbox.getIDsFromSeqSet(uid, seqset) ids, err := mbox.getIDsFromSeqSet(uid, seqset)
if err != nil { if err != nil {
return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err) return err
} }
for _, id := range ids { for _, id := range ids {
if err := mbox.backend.Storage.MailMove(mbox.name, int(id), dest); err != nil { if nid, err := mbox.storage.MailMove(pk, mbox.name, int(id), dest); err == nil {
return err // Delete mail from Outbox before send in Queue
} if mbox.name == "Outbox" {
if mbox.name == "Outbox" { _ = mbox.storage.QueueDeleteDestinationForID(mbox.user.Username(), "Outbox", int(nid))
if err := mbox.backend.Storage.QueueDeleteDestinationForID("Outbox", int(id)); err != nil {
return err
} }
} }
} }

View File

@ -9,65 +9,41 @@
package imapserver package imapserver
import ( import (
"fmt"
"log"
"github.com/emersion/go-imap" "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 { // github.com/emersion/go-imap/backend/updates.go
imap.Command func (b *Backend) listenUpdates() {
} allEvents := b.Notify.Subscribe(notify.AllUsers)
func (h *IMAPNotifyHandler) Handle(conn server.Conn) error { go func() {
// TODO: Support setting NOTIFY subscriptions or not for event := range allEvents {
return nil if ev, ok := event.(*notify.EmailEvent); ok {
} // b.updates <- &backend.MailboxUpdate{
// Update: backend.NewUpdate(ev.GetAccountPK(), ev.Mailbox()),
type IMAPNotify struct { // MailboxStatus: &imap.MailboxStatus{
server *server.Server // Name: ev.MailBox,
log *log.Logger // Messages: uint32(ev.Count),
} // UidNext: uint32(ev.MessageId),
// },
func (ext *IMAPNotify) Capabilities(c server.Conn) []string { // }
if c.Context().State&imap.AuthenticatedState != 0 { // b.updates <- &backend.MessageUpdate{
return []string{"NOTIFY"} // Update: backend.NewUpdate(ev.AccountPK, ev.MailBox),
} // Message: imap.NewMessage(uint32(ev.Count), []imap.FetchItem{imap.FetchAll}),
return nil // }
} b.updates <- &backend.MailboxUpdate{
Update: backend.NewUpdate(ev.GetAccountPK(), ev.MailBox),
func (ext *IMAPNotify) Command(name string) server.HandlerFactory { MailboxStatus: &imap.MailboxStatus{
if name != "NOTIFY" { Name: ev.MailBox,
return nil Messages: uint32(ev.Count),
} UidNext: uint32(ev.MessageId),
return func() server.Handler { Items: map[imap.StatusItem]interface{}{imap.StatusMessages: "", imap.StatusUidNext: ev.Count},
return &IMAPNotifyHandler{} },
} }
} b.Log.Println("IMAP Notify: ", ev.Username())
}
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),
)
} }
_ = c.WriteResp(&imap.StatusResp{ }()
Type: resptype,
})
})
return nil
}
func NewIMAPNotify(s *server.Server, log *log.Logger) *IMAPNotify {
return &IMAPNotify{
server: s,
log: log,
}
} }

View File

@ -9,35 +9,38 @@
package imapserver package imapserver
import ( import (
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"github.com/emersion/go-imap" "github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/backend"
"github.com/neilalexander/yggmail/internal/account"
"github.com/neilalexander/yggmail/internal/storage"
) )
type User struct { type User struct {
backend *Backend //backend *Backend
storage storage.Storage
username string username string
session *account.Session
conn *imap.ConnInfo conn *imap.ConnInfo
log *log.Logger log *log.Logger
} }
func (u *User) Username() string { 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) { 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 { if err != nil {
return nil, err return nil, err
} }
for _, mailbox := range names { for _, mailbox := range names {
mailboxes = append(mailboxes, &Mailbox{ mailboxes = append(mailboxes, &Mailbox{
backend: u.backend, storage: u.storage,
user: u, user: u,
name: mailbox, 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) { func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) {
if name == "" { if name == "" {
return &Mailbox{ return &Mailbox{
backend: u.backend, storage: u.storage,
user: u, user: u,
name: "", name: "",
}, nil }, nil
} }
ok, _ := u.backend.Storage.MailboxSelect(name) ok, _ := u.storage.MailboxSelect(u.username, name)
if !ok { if !ok {
return nil, fmt.Errorf("mailbox %q not found", name) return nil, fmt.Errorf("mailbox %q not found", name)
} }
return &Mailbox{ return &Mailbox{
backend: u.backend, storage: u.storage,
user: u, user: u,
name: name, name: name,
}, nil }, nil
@ -68,13 +71,13 @@ func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) {
func (u *User) CreateMailbox(name string) error { func (u *User) CreateMailbox(name string) error {
u.log.Printf("Creating mailbox '%s'...\n", name) u.log.Printf("Creating mailbox '%s'...\n", name)
if e := u.backend.Storage.MailboxCreate(name); e != nil { if e := u.storage.MailboxCreate(u.username, name); e != nil {
u.log.Printf("Error creating mailbox '%s': %v\n", name, e); u.log.Printf("Error creating mailbox '%s': %v\n", name, e)
return e; return e
} }
u.log.Printf("Created mailbox '%s'\n", name); u.log.Printf("Created mailbox '%s'\n", name)
return nil; return nil
} }
func (u *User) DeleteMailbox(name string) error { func (u *User) DeleteMailbox(name string) error {
@ -82,12 +85,12 @@ func (u *User) DeleteMailbox(name string) error {
case "INBOX", "Outbox", "Sent": case "INBOX", "Outbox", "Sent":
return errors.New("Cannot delete " + name) return errors.New("Cannot delete " + name)
default: default:
if e := u.backend.Storage.MailboxDelete(name); e != nil { if e := u.storage.MailboxDelete(u.username, name); e != nil {
u.log.Printf("Error deleting mailbox '%s': %v\n", name, e) u.log.Printf("Error deleting mailbox '%s': %v\n", name, e)
return e; return e
} else { } else {
u.log.Printf("Deleted mailbox '%s'\n", name) 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": case "INBOX", "Outbox", "Sent":
return errors.New("Cannot rename " + existingName) return errors.New("Cannot rename " + existingName)
default: default:
return u.backend.Storage.MailboxRename(existingName, newName) return u.storage.MailboxRename(u.username, existingName, newName)
} }
} }

137
internal/notify/notify.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}()
}

114
internal/remote/client.go Normal file
View File

@ -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)
}

292
internal/remote/handlers.go Normal file
View File

@ -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
}
}

275
internal/remote/io.go Normal file
View File

@ -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
}

210
internal/remote/server.go Normal file
View File

@ -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
}

111
internal/remote/sign.go Normal file
View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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
}

View File

@ -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;
}

203
internal/shell/shell.go Normal file
View File

@ -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
}

View File

@ -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 * 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 * License, v. 2.0. If a copy of the MPL was not distributed with this
@ -9,18 +9,23 @@
package smtpsender package smtpsender
import ( import (
"crypto/ed25519"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/mail" "net/mail"
"sync" "sync"
"time" "time"
"github.com/emersion/go-smtp"
"github.com/neilalexander/yggmail/internal/config" "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"
"github.com/neilalexander/yggmail/internal/storage/types"
"github.com/neilalexander/yggmail/internal/transport" "github.com/neilalexander/yggmail/internal/transport"
"github.com/neilalexander/yggmail/internal/utils" "github.com/neilalexander/yggmail/internal/utils"
"go.uber.org/atomic" "go.uber.org/atomic"
@ -31,15 +36,19 @@ type Queues struct {
Log *log.Logger Log *log.Logger
Transport transport.Transport Transport transport.Transport
Storage storage.Storage 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{ qs := &Queues{
Config: config, Config: config,
Log: log, Log: log,
Transport: transport, Transport: transport,
Storage: storage, Storage: storage,
RPCClient: rpcClient,
Notify: notify,
} }
time.AfterFunc(time.Second*5, qs.manager) time.AfterFunc(time.Second*5, qs.manager)
return qs return qs
@ -51,142 +60,164 @@ func (qs *Queues) manager() {
return return
} }
for _, destination := range destinations { for _, destination := range destinations {
_, _ = qs.queueFor(destination) _ = qs.QueueForServer(destination)
} }
time.AfterFunc(time.Minute, qs.manager) time.AfterFunc(time.Minute, qs.manager)
} }
func (qs *Queues) QueueFor(from string, rcpts []string, content []byte) error { func (qs *Queues) QueueFor(storage storage.Storage, senderPK string, from string, rcpts []string, m io.Reader, size int) error {
pid, err := qs.Storage.MailCreate("Outbox", content) pid, err := storage.MailCreate(senderPK, "Outbox", m, size)
if err != nil { if err != nil {
return fmt.Errorf("q.queues.Storage.MailCreate: %w", err) return fmt.Errorf("storage.MailCreate(Outbox): %w", err)
} }
for _, rcpt := range rcpts { for _, rcpt := range rcpts {
addr, err := mail.ParseAddress(rcpt) addr, err := mail.ParseAddress(rcpt)
if err != nil { 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 continue
} }
if err := qs.Storage.QueueInsertDestinationForID(host, pid, from, rcpt); err != nil { dPk, _, err := utils.ParseAddress(addr.Address)
return fmt.Errorf("qs.Storage.QueueInsertDestinationForID: %w", err) 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 return nil
} }
func (qs *Queues) queueFor(server string) (*Queue, error) { func (qs *Queues) QueueForServer(server string) error {
v, _ := qs.queues.LoadOrStore(server, &Queue{ v, _ := qs.queues.LoadOrStore(server, &Queue{
queues: qs, queues: qs,
destination: server, destination: server,
}) })
q, ok := v.(*Queue) q, ok := v.(*Queue)
if !ok { if !ok {
return nil, fmt.Errorf("type assertion error") return fmt.Errorf("type assertion error")
} }
if q.running.CompareAndSwap(false, true) { if q.running.CompareAndSwap(false, true) {
go q.run() go q.run()
} }
return q, nil return nil
} }
type Queue struct { type Queue struct {
queues *Queues queues *Queues
// hex16
destination string destination string
running atomic.Bool running atomic.Bool
} }
func (q *Queue) run() { func (q *Queue) run() {
defer q.running.Store(false) defer q.running.Store(false)
for {
refs, err := q.queues.Storage.QueueMailIDsForDestination(q.destination) refs, err := q.queues.Storage.QueueMailIDsForDestination(q.destination)
if err != nil { if err != nil || len(refs) == 0 {
q.queues.Log.Println("Error with queue:", err) return
}
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
} }
q.queues.Log.Println("Sending mail from", ref.From, "to", q.destination) for _, ref := range refs {
_, mail, err := q.queues.Storage.MailSelect(ref.AccountPubKey, "Outbox", ref.ID)
if err := func() error {
conn, err := q.queues.Transport.Dial(q.destination)
if err != nil { if err != nil {
return fmt.Errorf("q.queues.Transport.Dial: %w", err) q.handleError(ref, err)
} continue
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)
} }
if err := client.Mail(ref.From, nil); err != nil { q.queues.Log.Printf("Sending mail from %s (Acc: %s) to %s", ref.From, ref.AccountPubKey, q.destination)
q.queues.Log.Println("Remote server", q.destination, "did not accept MAIL:", err)
return fmt.Errorf("client.Mail: %w", err) deliver := q.deliverRemote
if q.destination == hex.EncodeToString(q.queues.Config.PublicKey) {
deliver = q.deliverLocal
} }
if err := client.Rcpt(ref.Rcpt); err != nil { if err := deliver(ref, mail.Body, mail.Size); err != nil {
q.queues.Log.Println("Remote server", q.destination, "did not accept RCPT:", err) q.queues.Log.Printf("Delivery to %s failed: %v. Will retry later.", q.destination, err)
return fmt.Errorf("client.Rcpt: %w", err) return
} }
writer, err := client.Data() q.finalize(ref)
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)
} }
} }
} }
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)
}
}

View File

@ -9,15 +9,16 @@
package smtpserver package smtpserver
import ( import (
"encoding/hex" "crypto/ed25519"
"fmt" "fmt"
"log" "log"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/neilalexander/yggmail/internal/config" "github.com/neilalexander/yggmail/internal/account"
"github.com/neilalexander/yggmail/internal/imapserver" "github.com/neilalexander/yggmail/internal/notify"
"github.com/neilalexander/yggmail/internal/smtpsender" "github.com/neilalexander/yggmail/internal/smtpsender"
"github.com/neilalexander/yggmail/internal/storage" "github.com/neilalexander/yggmail/internal/storage"
"github.com/neilalexander/yggmail/internal/storage/remote"
"github.com/neilalexander/yggmail/internal/utils" "github.com/neilalexander/yggmail/internal/utils"
) )
@ -29,35 +30,49 @@ const (
) )
type Backend struct { type Backend struct {
Mode BackendMode Mode BackendMode
Log *log.Logger Log *log.Logger
Config *config.Config Queues *smtpsender.Queues
Queues *smtpsender.Queues Storage storage.Storage
Storage storage.Storage Notify *notify.Notify
Notify *imapserver.IMAPNotify Account *account.Manager
NodePublicKey ed25519.PublicKey
// Config *config.Config
} }
func (b *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { func (b *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
switch b.Mode { switch b.Mode {
case BackendModeInternal: case BackendModeInternal:
// If our username is email-like, then take just the localpart // If our username is email-like, then take just the localpart
if pk, err := utils.ParseAddress(username); err == nil { dPk, aPk, err := utils.ParseAddress(username)
if !pk.Equal(b.Config.PublicKey) { if err != nil {
return nil, fmt.Errorf("failed to authenticate: wrong domain in username") return nil, fmt.Errorf("failed to authenticate: wrong domain or username")
}
} }
username = hex.EncodeToString(b.Config.PublicKey)
// The connection came from our local listener // Work in domain mode: Get account
if authed, err := b.Storage.ConfigTryPassword(password); err != nil { session, err := b.Account.Login(username, password)
b.Log.Printf("Failed to authenticate SMTP user %q due to error: %s", username, err) if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err) return nil, fmt.Errorf("failed to autheticate: %w", err)
} else if !authed {
b.Log.Printf("Failed to authenticate SMTP user %q\n", username)
return nil, smtp.ErrAuthRequired
} }
defer b.Log.Printf("Authenticated SMTP user 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{ return &SessionLocal{
backend: b, backend: b,
session: session,
storage: storage,
state: state, state: state,
}, nil }, 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") return nil, fmt.Errorf("not expecting anonymous connection on internal backend")
case BackendModeExternal: case BackendModeExternal:
// The connection came from our overlay listener, so we should check return nil, fmt.Errorf("not expecting anonymous connection on smtp. RPC Only")
// that they are who they claim to be // // The connection came from our overlay listener, so we should check
pks, err := hex.DecodeString(state.RemoteAddr.String()) // // that they are who they claim to be
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")
}
b.Log.Println("Incoming SMTP session from", remote) // // Transport Layer address. Hex
return &SessionRemote{ // nodePk, err := hex.DecodeString(state.RemoteAddr.String())
backend: b, // if err != nil {
state: state, // return nil, fmt.Errorf("hex.DecodeString: %w", err)
public: pks[:], // }
}, nil // // 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") return nil, fmt.Errorf("anonymous login failed")

View File

@ -9,20 +9,23 @@
package smtpserver package smtpserver
import ( import (
"bytes" "crypto/ed25519"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"time"
"github.com/emersion/go-message"
"github.com/emersion/go-smtp" "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"
"github.com/neilalexander/yggmail/internal/utils/e2ee"
) )
type SessionLocal struct { type SessionLocal struct {
backend *Backend backend *Backend
storage storage.Storage
state *smtp.ConnectionState state *smtp.ConnectionState
session *account.Session
from string from string
rcpt []string rcpt []string
} }
@ -30,13 +33,12 @@ type SessionLocal struct {
func (s *SessionLocal) Mail(from string, opts smtp.MailOptions) error { func (s *SessionLocal) Mail(from string, opts smtp.MailOptions) error {
s.rcpt = s.rcpt[:0] s.rcpt = s.rcpt[:0]
pk, err := utils.ParseAddress(from) _, aPk, err := utils.ParseAddress(from)
if err != nil { if err != nil {
return fmt.Errorf("parseAddress: %w", err) return fmt.Errorf("parseAddress: %w", err)
} }
if !aPk.Equal(s.session.Publickey()) {
if !pk.Equal(s.backend.Config.PublicKey) { return fmt.Errorf("not allowed to send outgoing mail as account: %s", utils.EncodeString(aPk))
return fmt.Errorf("not allowed to send outgoing mail as %s", from)
} }
s.from = from s.from = from
@ -49,34 +51,36 @@ func (s *SessionLocal) Rcpt(to string) error {
} }
func (s *SessionLocal) Data(r io.Reader) 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 { if err != nil {
return fmt.Errorf("message.Read: %w", err) return err
} }
m.Header.Add( var rcpt []ed25519.PublicKey
"Received", fmt.Sprintf("from %s by Yggmail %s; %s", for _, r := range s.rcpt {
s.state.RemoteAddr.String(), _, aPk, err := utils.ParseAddress(r)
hex.EncodeToString(s.backend.Config.PublicKey), if err != nil {
time.Now().String(), s.backend.Log.Println("Data: Parse address: cant parse: ", r)
), continue
) }
if !m.Header.Has("Date") { rcpt = append(rcpt, aPk)
m.Header.Add( }
"Date", time.Now().UTC().Format(time.RFC822), 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 payload, size, err = remote.ReadRawData(payload)
if err := m.WriteTo(&b); err != nil { if err != nil {
return fmt.Errorf("m.WriteTo: %w", err) 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) 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 return nil
} }

View File

@ -1,98 +1,119 @@
/* // /*
* Copyright (c) 2021 Neil Alexander // - Copyright (c) 2021 Neil Alexander
* // *
* This Source Code Form is subject to the terms of the Mozilla Public // - 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 // - 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/. // - file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ // */
package smtpserver package smtpserver
import ( //
"bytes" //
"crypto/ed25519" // WAS DEPRECATED BUT ARCHIVED FOR RE-IMPLEMENTATION ON PROTOBUFF
"encoding/hex" //
"fmt" //
"io"
"time"
"github.com/emersion/go-message" //
"github.com/emersion/go-smtp" // import (
"github.com/neilalexander/yggmail/internal/utils" // "bytes"
) // "crypto/ed25519"
// "encoding/hex"
type SessionRemote struct { // "fmt"
backend *Backend // "io"
state *smtp.ConnectionState // "time"
public ed25519.PublicKey //
from string // "github.com/emersion/go-message"
} // "github.com/emersion/go-smtp"
// "github.com/neilalexander/yggmail/internal/notify"
func (s *SessionRemote) Mail(from string, opts smtp.MailOptions) error { // "github.com/neilalexander/yggmail/internal/utils"
pk, err := utils.ParseAddress(from) // )
if err != nil { //
return fmt.Errorf("mail.ParseAddress: %w", err) // type SessionRemote struct {
} // backend *Backend
// state *smtp.ConnectionState
if remote := s.state.RemoteAddr.String(); hex.EncodeToString(pk) != remote { // public ed25519.PublicKey
return fmt.Errorf("not allowed to send incoming mail as %s", from) // from string
} // recipient string
// }
s.from = from //
return nil // func (s *SessionRemote) Mail(from string, opts smtp.MailOptions) error {
} // dPk, _, err := utils.ParseAddress(from)
// if err != nil {
func (s *SessionRemote) Rcpt(to string) error { // return fmt.Errorf("mail.ParseAddress: %w", err)
pk, err := utils.ParseAddress(to) // }
if err != nil { // // Tranport layer work on hex16 addresses
return fmt.Errorf("mail.ParseAddress: %w", err) // remote, err := hex.DecodeString(s.state.RemoteAddr.String())
} //
// rPk := ed25519.PublicKey(remote)
if !pk.Equal(s.backend.Config.PublicKey) { // if err != nil {
return fmt.Errorf("unexpected recipient for wrong domain") // return fmt.Errorf("hex.Decode error decode key")
} // }
// if !dPk.Equal(rPk) {
return nil // return fmt.Errorf("not allowed to send incoming mail as %s", from)
} // }
//
func (s *SessionRemote) Data(r io.Reader) error { // s.from = from
m, err := message.Read(r) // return nil
if err != nil { // }
return fmt.Errorf("message.Read: %w", err) //
} // func (s *SessionRemote) Rcpt(to string) error {
// dPk, aPk, err := utils.ParseAddress(to)
m.Header.Add( // if err != nil {
"Received", fmt.Sprintf("from Yggmail %s; %s", // return fmt.Errorf("mail.ParseAddress: %w", err)
hex.EncodeToString(s.public), // }
time.Now().String(), //
), // if !dPk.Equal(s.backend.NodePublicKey) {
) // return fmt.Errorf("unexpected recipient for wrong domain or username")
m.Header.Add( // }
"Delivery-Date", time.Now().UTC().Format(time.RFC822), // if _, err := s.backend.Account.GetAccount(utils.EncodeString(aPk)); err != nil {
) // return fmt.Errorf("unexpected recipient for wrong domain or username")
// }
var b bytes.Buffer //
if err := m.WriteTo(&b); err != nil { // s.recipient = utils.EncodeString(aPk)
return fmt.Errorf("m.WriteTo: %w", err) // return nil
} // }
//
if id, err := s.backend.Storage.MailCreate("INBOX", b.Bytes()); err != nil { // func (s *SessionRemote) Data(r io.Reader) error {
return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err) // m, err := message.Read(r)
} else { // if err != nil {
s.backend.Log.Printf("Stored new mail from %s", s.from) // return fmt.Errorf("message.Read: %w", err)
// }
if count, err := s.backend.Storage.MailCount("INBOX"); err == nil { //
if err := s.backend.Notify.NotifyNew(id, count); err != nil { // // TODO: Verify Message with Header:
s.backend.Log.Println("Failed to notify:", s.from) // // Header: Sig-ED25519
} //
} // m.Header.Add(
} // "Received", fmt.Sprintf("from Yggmail %s; %s",
// utils.EncodeString(s.public),
return nil // time.Now().String(),
} // ),
// )
func (s *SessionRemote) Reset() {} // m.Header.Add(
// "Delivery-Date", time.Now().UTC().Format(time.RFC822),
func (s *SessionRemote) Logout() error { // )
return nil //
} // 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
// }

View File

@ -10,16 +10,16 @@ package smtpserver
import ( import (
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/neilalexander/yggmail/internal/imapserver" "github.com/neilalexander/yggmail/internal/notify"
) )
type SMTPServer struct { type SMTPServer struct {
server *smtp.Server server *smtp.Server
backend smtp.Backend 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{ s := &SMTPServer{
server: smtp.NewServer(backend), server: smtp.NewServer(backend),
backend: backend, backend: backend,

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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
}

View File

@ -21,12 +21,14 @@ type SQLite3Storage struct {
*TableMailboxes *TableMailboxes
*TableMails *TableMails
*TableQueue *TableQueue
*TableAccounts
db *sql.DB db *sql.DB
writer *Writer writer *Writer
} }
func NewSQLite3StorageStorage(filename string) (*SQLite3Storage, error) { 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 { if err != nil {
return nil, fmt.Errorf("sql.Open: %w", err) return nil, fmt.Errorf("sql.Open: %w", err)
} }
@ -40,6 +42,11 @@ func NewSQLite3StorageStorage(filename string) (*SQLite3Storage, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("NewTableConfig: %w", err) 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) s.TableMailboxes, err = NewTableMailboxes(db, s.writer)
if err != nil { if err != nil {
return nil, fmt.Errorf("NewTableMailboxes: %w", err) return nil, fmt.Errorf("NewTableMailboxes: %w", err)

View File

@ -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
})
}

View File

@ -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 * 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 * 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 subscribeMailbox *sql.Stmt
} }
// Мы изменили схему: PRIMARY KEY теперь составной (аккаунт + папка)
const mailboxesSchema = ` const mailboxesSchema = `
CREATE TABLE IF NOT EXISTS mailboxes ( CREATE TABLE IF NOT EXISTS mailboxes (
mailbox TEXT NOT NULL DEFAULT('INBOX'), account_pubkey TEXT NOT NULL,
subscribed BOOLEAN NOT NULL DEFAULT 1, mailbox TEXT NOT NULL DEFAULT('INBOX'),
PRIMARY KEY(mailbox) subscribed BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY(account_pubkey, mailbox),
FOREIGN KEY(account_pubkey) REFERENCES accounts(pubkey) ON DELETE CASCADE
); );
` `
const mailboxesList = ` const mailboxesList = `
SELECT mailbox FROM mailboxes SELECT mailbox FROM mailboxes WHERE account_pubkey = $1
` `
const mailboxesListSubscribed = ` const mailboxesListSubscribed = `
SELECT mailbox FROM mailboxes WHERE subscribed = 1 SELECT mailbox FROM mailboxes WHERE account_pubkey = $1 AND subscribed = 1
` `
const mailboxesSelect = ` const mailboxesSelect = `
SELECT mailbox FROM mailboxes WHERE mailbox = $1 SELECT mailbox FROM mailboxes WHERE account_pubkey = $1 AND mailbox = $2
` `
const mailboxesCreate = ` const mailboxesCreate = `
INSERT OR IGNORE INTO mailboxes (mailbox) VALUES($1) INSERT OR IGNORE INTO mailboxes (account_pubkey, mailbox) VALUES($1, $2)
` `
const mailboxesRename = ` const mailboxesRename = `
UPDATE mailboxes SET mailbox = $1 WHERE mailbox = $2 UPDATE mailboxes SET mailbox = $2 WHERE account_pubkey = $1 AND mailbox = $3
` `
const mailboxesDelete = ` const mailboxesDelete = `
DELETE FROM mailboxes WHERE mailbox = $1 DELETE FROM mailboxes WHERE account_pubkey = $1 AND mailbox = $2
` `
const mailboxesSubscribe = ` 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) { 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) t.listMailboxes, err = db.Prepare(mailboxesList)
if err != nil { 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) t.listMailboxesSubscribed, err = db.Prepare(mailboxesListSubscribed)
if err != nil { 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) t.selectMailboxes, err = db.Prepare(mailboxesSelect)
if err != nil { if err != nil {
@ -101,16 +105,19 @@ func NewTableMailboxes(db *sql.DB, writer *Writer) (*TableMailboxes, error) {
return t, nil 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 stmt := t.listMailboxes
if onlySubscribed { if onlySubscribed {
stmt = t.listMailboxesSubscribed stmt = t.listMailboxesSubscribed
} }
rows, err := stmt.Query() rows, err := stmt.Query(accountPubKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("t.listMailboxes.Query: %w", err) return nil, fmt.Errorf("t.listMailboxes.Query: %w", err)
} }
defer rows.Close() // nolint:errcheck defer rows.Close()
var mailboxes []string var mailboxes []string
for rows.Next() { for rows.Next() {
var mailbox string var mailbox string
@ -122,48 +129,47 @@ func (t *TableMailboxes) MailboxList(onlySubscribed bool) ([]string, error) {
return mailboxes, nil return mailboxes, nil
} }
func (t *TableMailboxes) MailboxSelect(mailbox string) (bool, error) { func (t *TableMailboxes) MailboxSelect(accountPubKey string, mailbox string) (bool, error) {
row := t.selectMailboxes.QueryRow(mailbox) row := t.selectMailboxes.QueryRow(accountPubKey, mailbox)
if err := row.Err(); err != nil && err != sql.ErrNoRows {
return false, fmt.Errorf("row.Err: %w", err)
} else if err == sql.ErrNoRows {
return false, nil
}
var got string 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 false, fmt.Errorf("row.Scan: %w", err)
} }
return mailbox == got, nil 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 { 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 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 { 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 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 { 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 return err
}) })
} }
func (t *TableMailboxes) MailboxSubscribe(name string, subscribed bool) error { func (t *TableMailboxes) MailboxSubscribe(accountPubKey string, name string, subscribed bool) error {
sn := 1 sn := 1
if !subscribed { if !subscribed {
sn = 0 sn = 0
} }
return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { 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 return err
}) })
} }

View File

@ -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 * 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 * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
package sqlite3 package sqlite3
import ( import (
"bytes"
"database/sql" "database/sql"
"fmt" "fmt"
"io"
"time" "time"
"github.com/neilalexander/yggmail/internal/storage/types" "github.com/neilalexander/yggmail/internal/storage/types"
@ -34,84 +36,89 @@ type TableMails struct {
} }
const mailsSchema = ` const mailsSchema = `
CREATE TABLE IF NOT EXISTS mails ( CREATE TABLE IF NOT EXISTS mails (
mailbox TEXT NOT NULL, account_pubkey TEXT NOT NULL,
id INTEGER NOT NULL DEFAULT 1, mailbox TEXT NOT NULL,
mail BLOB NOT NULL, id INTEGER NOT NULL DEFAULT 1,
datetime INTEGER NOT NULL, mail BLOB NOT NULL,
seen BOOLEAN NOT NULL DEFAULT 0, -- the mail has been read mail_file TEXT NOT NULL DEFAULT '',
answered BOOLEAN NOT NULL DEFAULT 0, -- the mail has been replied to size INTEGER NOT NULL DEFAULT 0,
flagged BOOLEAN NOT NULL DEFAULT 0, -- the mail has been flagged for later attention datetime INTEGER NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT 0, -- the email is marked for deletion at next EXPUNGE seen BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (mailbox, id), answered BOOLEAN NOT NULL DEFAULT 0,
FOREIGN KEY (mailbox) REFERENCES mailboxes(mailbox) ON DELETE CASCADE ON UPDATE CASCADE 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 ( -- View for sequential numbering (important for IMAP FETCH sequence numbers)
SELECT ROW_NUMBER() OVER (PARTITION BY mailbox) AS seq, * FROM mails 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 mailbox, id; )
ORDER BY account_pubkey, mailbox, id;
` `
const selectMailsStmt = ` const selectMailsStmt = `
SELECT * FROM inboxes SELECT seq, id, mail, mail_file, size, datetime, seen, answered, flagged, deleted FROM inboxes
ORDER BY mailbox, id WHERE account_pubkey = $1 AND mailbox = $2
ORDER BY id
` `
const selectMailStmt = ` const selectMailStmt = `
SELECT seq, id, mail, datetime, seen, answered, flagged, deleted FROM inboxes SELECT seq, id, mail, mail_file, size, datetime, seen, answered, flagged, deleted FROM inboxes
WHERE mailbox = $1 AND id = $2 WHERE account_pubkey = $1 AND mailbox = $2 AND id = $3
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
` `
const insertMailStmt = ` const insertMailStmt = `
INSERT INTO mails (mailbox, id, mail, datetime) VALUES( INSERT INTO mails (account_pubkey, mailbox, id, mail, mail_file, size, datetime) VALUES(
$1, ( $1, $2, (
SELECT IFNULL(MAX(id)+1,1) AS id FROM mails SELECT IFNULL(MAX(id)+1,1) AS id FROM mails
WHERE mailbox = $1 WHERE account_pubkey = $1 AND mailbox = $2
), $2, $3 ), $3, $4, $5, $6
) )
RETURNING id; 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 = ` const selectIDForSeqStmt = `
SELECT id FROM inboxes SELECT id FROM inboxes
WHERE mailbox = $1 AND seq = $2 WHERE account_pubkey = $1 AND mailbox = $2 AND seq = $3
` `
const selectMailNextID = ` const selectMailNextIDStmt = `
SELECT IFNULL(MAX(id)+1,1) AS id FROM mails SELECT IFNULL(MAX(id)+1,1) AS id FROM mails
WHERE mailbox = $1 WHERE account_pubkey = $1 AND mailbox = $2
` `
const updateMailFlagsStmt = ` 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 = ` 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 = ` 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 = ` 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) { 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 { if err != nil {
return nil, fmt.Errorf("db.Exec: %w", err) return nil, fmt.Errorf("db.Exec: %w", err)
} }
t.selectMails, err = db.Prepare(selectMailsStmt) t.selectMails, err = db.Prepare(selectMailsStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(selectMailsStmt): %w", err) return nil, err
} }
t.selectMail, err = db.Prepare(selectMailStmt) t.selectMail, err = db.Prepare(selectMailStmt)
if err != nil { 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 { if err != nil {
return nil, fmt.Errorf("db.Prepare(selectMailNextID): %w", err) return nil, err
} }
t.selectIDForSeq, err = db.Prepare(selectIDForSeqStmt) t.selectIDForSeq, err = db.Prepare(selectIDForSeqStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err) return nil, err
} }
t.searchMail, err = db.Prepare(searchMailStmt) t.searchMail, err = db.Prepare(searchMailStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err) return nil, err
} }
t.createMail, err = db.Prepare(insertMailStmt) t.createMail, err = db.Prepare(insertMailStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(insertMailStmt): %w", err) return nil, err
} }
t.updateMailFlags, err = db.Prepare(updateMailFlagsStmt) t.updateMailFlags, err = db.Prepare(updateMailFlagsStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(updateMailSeenStmt): %w", err) return nil, err
} }
t.deleteMail, err = db.Prepare(deleteMailStmt) t.deleteMail, err = db.Prepare(deleteMailStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(deleteMailStmt): %w", err) return nil, err
} }
t.expungeMail, err = db.Prepare(expungeMailStmt) t.expungeMail, err = db.Prepare(expungeMailStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(expungeMailStmt): %w", err) return nil, err
} }
t.countMails, err = db.Prepare(selectMailCountStmt) t.countMails, err = db.Prepare(selectMailCountStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(selectMailCountStmt): %w", err) return nil, err
} }
t.countUnseenMails, err = db.Prepare(selectMailUnseenStmt) t.countUnseenMails, err = db.Prepare(selectMailUnseenStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(selectMailUnseenStmt): %w", err) return nil, err
} }
t.moveMail, err = db.Prepare(moveMailStmt) t.moveMail, err = db.Prepare(moveMailStmt)
if err != nil { if err != nil {
return nil, fmt.Errorf("db.Prepare(moveMailStmt): %w", err) return nil, err
} }
return t, nil 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 var id int
err := t.writer.Do(t.db, nil, func(txn *sql.Tx) error { 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 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 seq int
var datetime int64 var datetime int64
mail := &types.Mail{} mail := &types.Mail{}
err := t.selectMail.QueryRow(mailbox, id).Scan( err := t.selectMail.QueryRow(accountPubKey, mailbox, id).Scan(
&seq, &mail.ID, &mail.Mail, &datetime, &seq, &mail.ID, &mail.RawData, &mail.MailFile, &mail.Size, &datetime,
&mail.Seen, &mail.Answered, &mail.Flagged, &mail.Deleted, &mail.Seen, &mail.Answered, &mail.Flagged, &mail.Deleted,
) )
if err != nil {
return 0, nil, err
}
mail.Date = time.Unix(datetime, 0) 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 var ids []uint32
rows, err := t.searchMail.Query(mailbox) rows, err := t.searchMail.Query(accountPubKey, mailbox)
if err != nil { if err != nil {
return nil, fmt.Errorf("t.searchMail.Query: %w", err) return nil, fmt.Errorf("t.searchMail.Query: %w", err)
} }
defer rows.Close() // nolint:errcheck defer rows.Close()
for rows.Next() { for rows.Next() {
var id uint32 var id uint32
if err := rows.Scan(&id); err != nil { if err := rows.Scan(&id); err != nil {
@ -211,54 +244,59 @@ func (t *TableMails) MailSearch(mailbox string) ([]uint32, error) {
return ids, nil return ids, nil
} }
func (t *TableMails) MailNextID(mailbox string) (int, error) { func (t *TableMails) MailNextID(accountPubKey string, mailbox string) (int, error) {
var id int var id int
err := t.selectMailNextID.QueryRow(mailbox).Scan(&id) err := t.selectMailNextID.QueryRow(accountPubKey, mailbox).Scan(&id)
return id, err 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 var id int
err := t.selectIDForSeq.QueryRow(mailbox, seq).Scan(&id) err := t.selectIDForSeq.QueryRow(accountPubKey, mailbox, seq).Scan(&id)
return id, err return id, err
} }
func (t *TableMails) MailUnseen(mailbox string) (int, error) { func (t *TableMails) MailUnseen(accountPubKey string, mailbox string) (int, error) {
var unseen int var unseen int
err := t.countUnseenMails.QueryRow(mailbox).Scan(&unseen) err := t.countUnseenMails.QueryRow(accountPubKey, mailbox).Scan(&unseen)
return unseen, err 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 { 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 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 { 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 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 { 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 return err
}) })
} }
func (t *TableMails) MailCount(mailbox string) (int, error) { func (t *TableMails) MailCount(accountPubKey string, mailbox string) (int, error) {
var count int var count int
err := t.countMails.QueryRow(mailbox).Scan(&count) err := t.countMails.QueryRow(accountPubKey, mailbox).Scan(&count)
return count, err return count, err
} }
func (t *TableMails) MailMove(mailbox string, id int, destination string) error { func (t *TableMails) MailMove(accountPubKey string, mailbox string, id int, destination string) (int, error) {
return t.writer.Do(t.db, nil, func(txn *sql.Tx) error { var newID int
_, err := t.moveMail.Exec(destination, mailbox, id) 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 err
}) })
return newID, err
} }

View File

@ -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 * 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 * License, v. 2.0. If a copy of the MPL was not distributed with this
@ -27,13 +28,14 @@ type TableQueue struct {
const queueSchema = ` const queueSchema = `
CREATE TABLE IF NOT EXISTS queue ( CREATE TABLE IF NOT EXISTS queue (
destination TEXT NOT NULL, account_pubkey TEXT NOT NULL,
mailbox TEXT NOT NULL, destination TEXT NOT NULL,
id INTEGER NOT NULL, mailbox TEXT NOT NULL DEFAULT 'Outbox',
mail TEXT NOT NULL, id INTEGER NOT NULL,
rcpt TEXT NOT NULL, mail TEXT NOT NULL, -- Это поле From в оригинале
PRIMARY KEY (destination, mailbox, id), rcpt TEXT NOT NULL,
FOREIGN KEY (mailbox, id) REFERENCES mails(mailbox, id) ON DELETE CASCADE ON UPDATE CASCADE 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 = ` 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 ORDER BY id DESC
` `
const queueInsertDestinationForIDStmt = ` 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 = ` 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 = ` 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) { 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) return nil, fmt.Errorf("t.queueSelectDestinations.Query: %w", err)
} }
defer rows.Close() // nolint:errcheck defer rows.Close()
var destinations []string var destinations []string
for rows.Next() { for rows.Next() {
var destination string var destination string
@ -116,48 +118,49 @@ func (t *TableQueue) QueueMailIDsForDestination(destination string) ([]types.Que
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil 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 var ids []types.QueuedMail
for rows.Next() { for rows.Next() {
var aPk string
var id int var id int
var from, rcpt string 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) return nil, fmt.Errorf("rows.Scan: %w", err)
} }
ids = append(ids, types.QueuedMail{ ids = append(ids, types.QueuedMail{
ID: id, AccountPubKey: aPk,
From: from, ID: id,
Rcpt: rcpt, From: from,
Rcpt: rcpt,
}) })
} }
return ids, nil 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 { 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 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 { 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 return err
}) })
} }
func (t *TableQueue) QueueSelectIsMessagePendingSend(mailbox string, id int) (bool, error) { func (t *TableQueue) QueueSelectIsMessagePendingSend(pk string, mailbox string, id int) (bool, error) {
row := t.queueSelectIsMessagePendingSend.QueryRow(mailbox, id) row := t.queueSelectIsMessagePendingSend.QueryRow(pk, 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
}
var count int var count int
if err := row.Scan(&count); err != nil { if err := row.Scan(&count); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("row.Scan: %w", err) return false, fmt.Errorf("row.Scan: %w", err)
} }
return count > 0, nil return count > 0, nil

View File

@ -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 * 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 * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
package storage 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 { type Storage interface {
ConfigGet(key string) (string, error) ConfigGet(key string) (string, error)
@ -16,28 +32,33 @@ type Storage interface {
ConfigSetPassword(password string) error ConfigSetPassword(password string) error
ConfigTryPassword(password string) (bool, error) ConfigTryPassword(password string) (bool, error)
MailboxSelect(mailbox string) (bool, error) AccountGet(pk string) (*types.Account, error)
MailNextID(mailbox string) (int, error) AccountCreate(acc *types.Account) error
MailIDForSeq(mailbox string, id int) (int, error) AccountDelete(pk string) error
MailUnseen(mailbox string) (int, error) AccountList() ([]*types.Account, 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
MailCreate(mailbox string, data []byte) (int, error) MailboxSelect(pk string, mailbox string) (bool, error)
MailSelect(mailbox string, id int) (int, *types.Mail, error) MailNextID(pk string, mailbox string) (int, error)
MailSearch(mailbox string) ([]uint32, error) MailIDForSeq(pk string, mailbox string, seq int) (int, error)
MailUpdateFlags(mailbox string, id int, seen, answered, flagged, deleted bool) error MailUnseen(pk string, mailbox string) (int, error)
MailDelete(mailbox string, id int) error MailboxList(pk string, onlySubscribed bool) ([]string, error)
MailExpunge(mailbox string) error MailboxCreate(pk string, name string) error
MailCount(mailbox string) (int, error) MailboxRename(pk string, old, new string) error
MailMove(mailbox string, id int, destination 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) QueueListDestinations() ([]string, error)
QueueMailIDsForDestination(destination string) ([]types.QueuedMail, error) QueueMailIDsForDestination(destination string) ([]types.QueuedMail, error)
QueueInsertDestinationForID(destination string, id int, from, rcpt string) error QueueInsertDestinationForID(pk string, destination string, id int, from, rcpt string) error
QueueDeleteDestinationForID(destination string, id int) error QueueDeleteDestinationForID(pk string, destination string, id int) error
QueueSelectIsMessagePendingSend(mailbox string, id int) (bool, error) QueueSelectIsMessagePendingSend(pk string, mailbox string, id int) (bool, error)
} }

View File

@ -8,21 +8,57 @@
package types package types
import "time" import (
"io"
"time"
)
type Mail struct { type Mail struct {
Mailbox string
ID int ID int
Mail []byte Body io.ReadCloser // Unified interface for content
MailFile string
Size uint32
Date time.Time Date time.Time
Seen bool Seen bool
Answered bool Answered bool
Flagged bool Flagged bool
Deleted 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 { type QueuedMail struct {
ID int AccountPubKey string
From string ID int
Rcpt string 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
} }

View File

@ -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 * 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 * License, v. 2.0. If a copy of the MPL was not distributed with this
@ -10,33 +11,53 @@ package utils
import ( import (
"crypto/ed25519" "crypto/ed25519"
"encoding/hex" "encoding/base32"
"fmt" "fmt"
"strings" "strings"
) )
const Domain = "yggmail" func CreateAddress(aPk, dPk ed25519.PublicKey) string {
func CreateAddress(pk ed25519.PublicKey) string {
return fmt.Sprintf( return fmt.Sprintf(
"%s@%s", "%s@%s",
pk, Domain, EncodeString(aPk), EncodeString(dPk),
) )
} }
func ParseAddress(email string) (ed25519.PublicKey, error) { func EncodeString(k []byte) string {
at := strings.LastIndex(email, "@") return strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(k))
if at == 0 { }
return nil, fmt.Errorf("invalid email address")
} func DecodeString(s string) ([]byte, error) {
if email[at+1:] != Domain { pk, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(s))
return nil, fmt.Errorf("invalid email domain")
}
pk, err := hex.DecodeString(email[:at])
if err != nil { if err != nil {
return nil, fmt.Errorf("hex.DecodeString: %w", err) return nil, fmt.Errorf("hex.DecodeString: %w", err)
} }
ed := make(ed25519.PublicKey, ed25519.PublicKeySize) if len(pk) == 0 {
copy(ed, pk) return nil, fmt.Errorf("utils.DecodeStrings: len %d", len(pk))
return ed, nil }
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
} }

63
internal/utils/age/age.go Normal file
View File

@ -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...)
}

View File

@ -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<<tobits - 1)
for idx, value := range data {
if value>>frombits != 0 {
return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits)
}
acc = acc<<frombits | uint32(value)
bits += frombits
for bits >= 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
}

92
internal/utils/crypt.go Normal file
View File

@ -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
}

View File

@ -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())
}

View File

@ -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]))
}

101
internal/utils/e2ee/msg.go Normal file
View File

@ -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
}

32
internal/utils/x25519.go Normal file
View File

@ -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
}

View File

@ -3,9 +3,10 @@ package welcome
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"log"
"github.com/emersion/go-message" "github.com/emersion/go-message"
"github.com/neilalexander/yggmail/internal/storage" "github.com/neilalexander/yggmail/internal/storage"
"log"
) )
const ( const (
@ -27,14 +28,14 @@ func Onboard(user string, storage storage.Storage, log *log.Logger) {
log.Println("Failure to generate welcome message") log.Println("Failure to generate welcome message")
} }
var welcomeId int 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) log.Printf("Failed to store welcome message: %v\n", e)
panic("See above") panic("See above")
} else { } else {
welcomeId = id 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") panic("Could not set flags on onboarding message")
} }