- 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
303 lines
8.7 KiB
Go
303 lines
8.7 KiB
Go
/*
|
|
* (Original Copyright (c) 2021 Neil Alexander)
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
package sqlite3
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/neilalexander/yggmail/internal/storage/types"
|
|
)
|
|
|
|
type TableMails struct {
|
|
db *sql.DB
|
|
writer *Writer
|
|
selectMails *sql.Stmt
|
|
selectMail *sql.Stmt
|
|
selectMailNextID *sql.Stmt
|
|
selectIDForSeq *sql.Stmt
|
|
searchMail *sql.Stmt
|
|
createMail *sql.Stmt
|
|
countMails *sql.Stmt
|
|
countUnseenMails *sql.Stmt
|
|
updateMailFlags *sql.Stmt
|
|
deleteMail *sql.Stmt
|
|
expungeMail *sql.Stmt
|
|
moveMail *sql.Stmt
|
|
}
|
|
|
|
const mailsSchema = `
|
|
CREATE TABLE IF NOT EXISTS mails (
|
|
account_pubkey TEXT NOT NULL,
|
|
mailbox TEXT NOT NULL,
|
|
id INTEGER NOT NULL DEFAULT 1,
|
|
mail BLOB NOT NULL,
|
|
mail_file TEXT NOT NULL DEFAULT '',
|
|
size INTEGER NOT NULL DEFAULT 0,
|
|
datetime INTEGER NOT NULL,
|
|
seen BOOLEAN NOT NULL DEFAULT 0,
|
|
answered BOOLEAN NOT NULL DEFAULT 0,
|
|
flagged BOOLEAN NOT NULL DEFAULT 0,
|
|
deleted BOOLEAN NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (account_pubkey, mailbox, id),
|
|
FOREIGN KEY (account_pubkey, mailbox) REFERENCES mailboxes(account_pubkey, mailbox) ON DELETE CASCADE ON UPDATE CASCADE
|
|
);
|
|
|
|
-- View for sequential numbering (important for IMAP FETCH sequence numbers)
|
|
CREATE VIEW IF NOT EXISTS inboxes AS SELECT * FROM (
|
|
SELECT ROW_NUMBER() OVER (PARTITION BY account_pubkey, mailbox ORDER BY id) AS seq, * FROM mails
|
|
)
|
|
ORDER BY account_pubkey, mailbox, id;
|
|
`
|
|
|
|
const selectMailsStmt = `
|
|
SELECT seq, id, mail, mail_file, size, datetime, seen, answered, flagged, deleted FROM inboxes
|
|
WHERE account_pubkey = $1 AND mailbox = $2
|
|
ORDER BY id
|
|
`
|
|
|
|
const selectMailStmt = `
|
|
SELECT seq, id, mail, mail_file, size, datetime, seen, answered, flagged, deleted FROM inboxes
|
|
WHERE account_pubkey = $1 AND mailbox = $2 AND id = $3
|
|
`
|
|
|
|
const insertMailStmt = `
|
|
INSERT INTO mails (account_pubkey, mailbox, id, mail, mail_file, size, datetime) VALUES(
|
|
$1, $2, (
|
|
SELECT IFNULL(MAX(id)+1,1) AS id FROM mails
|
|
WHERE account_pubkey = $1 AND mailbox = $2
|
|
), $3, $4, $5, $6
|
|
)
|
|
RETURNING id;
|
|
`
|
|
|
|
const selectMailCountStmt = `
|
|
SELECT COUNT(*) FROM mails WHERE account_pubkey = $1 AND mailbox = $2
|
|
`
|
|
|
|
const selectMailUnseenStmt = `
|
|
SELECT COUNT(*) FROM mails WHERE account_pubkey = $1 AND mailbox = $2 AND seen = 0
|
|
`
|
|
|
|
const searchMailStmt = `
|
|
SELECT id FROM mails
|
|
WHERE account_pubkey = $1 AND mailbox = $2
|
|
ORDER BY id
|
|
`
|
|
|
|
const selectIDForSeqStmt = `
|
|
SELECT id FROM inboxes
|
|
WHERE account_pubkey = $1 AND mailbox = $2 AND seq = $3
|
|
`
|
|
|
|
const selectMailNextIDStmt = `
|
|
SELECT IFNULL(MAX(id)+1,1) AS id FROM mails
|
|
WHERE account_pubkey = $1 AND mailbox = $2
|
|
`
|
|
|
|
const updateMailFlagsStmt = `
|
|
UPDATE mails SET seen = $1, answered = $2, flagged = $3, deleted = $4
|
|
WHERE account_pubkey = $5 AND mailbox = $6 AND id = $7
|
|
`
|
|
|
|
const deleteMailStmt = `
|
|
UPDATE mails SET deleted = 1 WHERE account_pubkey = $1 AND mailbox = $2 AND id = $3
|
|
`
|
|
|
|
const expungeMailStmt = `
|
|
DELETE FROM mails WHERE account_pubkey = $1 AND mailbox = $2 AND deleted = 1
|
|
`
|
|
|
|
const moveMailStmt = `
|
|
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) {
|
|
t := &TableMails{
|
|
db: db,
|
|
writer: writer,
|
|
}
|
|
_, err := db.Exec(mailsSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db.Exec: %w", err)
|
|
}
|
|
|
|
t.selectMails, err = db.Prepare(selectMailsStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.selectMail, err = db.Prepare(selectMailStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.selectMailNextID, err = db.Prepare(selectMailNextIDStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.selectIDForSeq, err = db.Prepare(selectIDForSeqStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.searchMail, err = db.Prepare(searchMailStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.createMail, err = db.Prepare(insertMailStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.updateMailFlags, err = db.Prepare(updateMailFlagsStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.deleteMail, err = db.Prepare(deleteMailStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.expungeMail, err = db.Prepare(expungeMailStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.countMails, err = db.Prepare(selectMailCountStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.countUnseenMails, err = db.Prepare(selectMailUnseenStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.moveMail, err = db.Prepare(moveMailStmt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
// MailRawCreate inserts mail metadata.
|
|
// data: content blob (can be nil/empty if stored in mailFile).
|
|
// mailFile: relative path in the BlobStore.
|
|
// size: pre-calculated size of the full message.
|
|
func (t *TableMails) MailCreateRaw(accountPubKey string, mailbox string, data io.Reader, mailFile string, size int64) (int, error) {
|
|
var id int
|
|
err := t.writer.Do(t.db, nil, func(txn *sql.Tx) error {
|
|
if data == nil {
|
|
data = bytes.NewBuffer(nil)
|
|
}
|
|
body, err := io.ReadAll(data)
|
|
if err != nil {
|
|
return fmt.Errorf("MailRawCreate: error read data: %w", err)
|
|
}
|
|
// Use empty slice instead of nil to satisfy NOT NULL constraint if necessary
|
|
if body == nil {
|
|
body = []byte{}
|
|
}
|
|
return t.createMail.QueryRow(accountPubKey, mailbox, body, mailFile, size, time.Now().Unix()).Scan(&id)
|
|
})
|
|
return id, err
|
|
}
|
|
|
|
func (t *TableMails) MailCreate(accountPubKey string, mailbox string, data io.Reader, size int) (int, error) {
|
|
panic("sqlite3: MailCreate(stream) must not be called directly. Use LocalStorage wrapper.")
|
|
}
|
|
|
|
// MailSelect retrieves mail metadata.
|
|
// Note: The Body field in types.Mail will be populated by the storage coordinator.
|
|
func (t *TableMails) MailSelect(accountPubKey string, mailbox string, id int) (int, *types.Mail, error) {
|
|
var seq int
|
|
var datetime int64
|
|
mail := &types.Mail{}
|
|
err := t.selectMail.QueryRow(accountPubKey, mailbox, id).Scan(
|
|
&seq, &mail.ID, &mail.RawData, &mail.MailFile, &mail.Size, &datetime,
|
|
&mail.Seen, &mail.Answered, &mail.Flagged, &mail.Deleted,
|
|
)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
mail.Date = time.Unix(datetime, 0)
|
|
return seq, mail, nil
|
|
}
|
|
|
|
func (t *TableMails) MailSearch(accountPubKey string, mailbox string) ([]uint32, error) {
|
|
var ids []uint32
|
|
rows, err := t.searchMail.Query(accountPubKey, mailbox)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("t.searchMail.Query: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id uint32
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("rows.Scan: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (t *TableMails) MailNextID(accountPubKey string, mailbox string) (int, error) {
|
|
var id int
|
|
err := t.selectMailNextID.QueryRow(accountPubKey, mailbox).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func (t *TableMails) MailIDForSeq(accountPubKey string, mailbox string, seq int) (int, error) {
|
|
var id int
|
|
err := t.selectIDForSeq.QueryRow(accountPubKey, mailbox, seq).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func (t *TableMails) MailUnseen(accountPubKey string, mailbox string) (int, error) {
|
|
var unseen int
|
|
err := t.countUnseenMails.QueryRow(accountPubKey, mailbox).Scan(&unseen)
|
|
return unseen, err
|
|
}
|
|
|
|
func (t *TableMails) MailUpdateFlags(accountPubKey string, mailbox string, id int, seen, answered, flagged, deleted bool) error {
|
|
return t.writer.Do(t.db, nil, func(txn *sql.Tx) error {
|
|
_, err := t.updateMailFlags.Exec(seen, answered, flagged, deleted, accountPubKey, mailbox, id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (t *TableMails) MailDelete(accountPubKey string, mailbox string, id int) error {
|
|
return t.writer.Do(t.db, nil, func(txn *sql.Tx) error {
|
|
_, err := t.deleteMail.Exec(accountPubKey, mailbox, id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (t *TableMails) MailExpunge(accountPubKey string, mailbox string) error {
|
|
return t.writer.Do(t.db, nil, func(txn *sql.Tx) error {
|
|
_, err := t.expungeMail.Exec(accountPubKey, mailbox)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (t *TableMails) MailCount(accountPubKey string, mailbox string) (int, error) {
|
|
var count int
|
|
err := t.countMails.QueryRow(accountPubKey, mailbox).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (t *TableMails) MailMove(accountPubKey string, mailbox string, id int, destination string) (int, error) {
|
|
var newID int
|
|
err := t.writer.Do(t.db, nil, func(txn *sql.Tx) error {
|
|
if err := t.selectMailNextID.QueryRow(accountPubKey, destination).Scan(&newID); err != nil {
|
|
return err
|
|
}
|
|
_, err := t.moveMail.Exec(accountPubKey, mailbox, destination, newID, id)
|
|
return err
|
|
})
|
|
return newID, err
|
|
}
|