mirror of
https://github.com/neilalexander/yggmail.git
synced 2026-05-19 17:56:28 +03:00
Initial commit
This commit is contained in:
10
internal/config/config.go
Normal file
10
internal/config/config.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
PrivateKey ed25519.PrivateKey
|
||||
}
|
||||
33
internal/imapserver/backend.go
Normal file
33
internal/imapserver/backend.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend"
|
||||
"github.com/neilalexander/yggmail/internal/config"
|
||||
"github.com/neilalexander/yggmail/internal/storage"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
Config *config.Config
|
||||
Log *log.Logger
|
||||
Storage storage.Storage
|
||||
}
|
||||
|
||||
func (b *Backend) Login(_ *imap.ConnInfo, username, password string) (backend.User, error) {
|
||||
if authed, err := b.Storage.TryAuthenticate(username, password); err != nil {
|
||||
b.Log.Printf("Failed to authenticate IMAP user %q due to error: %s", username, err)
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||
} else if !authed {
|
||||
b.Log.Printf("Failed to authenticate IMAP user %q\n", username)
|
||||
return nil, backend.ErrInvalidCredentials
|
||||
}
|
||||
defer b.Log.Printf("Authenticated IMAP user %q\n", username)
|
||||
user := &User{
|
||||
backend: b,
|
||||
username: username,
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
20
internal/imapserver/imap.go
Normal file
20
internal/imapserver/imap.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
idle "github.com/emersion/go-imap-idle"
|
||||
"github.com/emersion/go-imap/server"
|
||||
)
|
||||
|
||||
type IMAPServer struct {
|
||||
server *server.Server
|
||||
backend *Backend
|
||||
}
|
||||
|
||||
func NewIMAPServer(backend *Backend) (*IMAPServer, error) {
|
||||
s := &IMAPServer{
|
||||
server: server.New(backend),
|
||||
backend: backend,
|
||||
}
|
||||
s.server.Enable(idle.NewExtension())
|
||||
return s, nil
|
||||
}
|
||||
293
internal/imapserver/mailbox.go
Normal file
293
internal/imapserver/mailbox.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend/backendutil"
|
||||
"github.com/emersion/go-message/textproto"
|
||||
)
|
||||
|
||||
type Mailbox struct {
|
||||
backend *Backend
|
||||
name string
|
||||
user *User
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) getIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]int32, error) {
|
||||
var ids []int32
|
||||
for _, set := range seqSet.Set {
|
||||
for i := set.Start; i <= set.Stop; i++ {
|
||||
if !uid {
|
||||
pid, err := mbox.backend.Storage.MailIDForSeq(mbox.user.username, mbox.name, int(i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mbox.backend.Storage.MailIDForSeq: %w", err)
|
||||
}
|
||||
ids = append(ids, int32(pid))
|
||||
} else {
|
||||
ids = append(ids, int32(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Name() string {
|
||||
return mbox.name
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Info() (*imap.MailboxInfo, error) {
|
||||
info := &imap.MailboxInfo{
|
||||
Attributes: []string{},
|
||||
Delimiter: "/",
|
||||
Name: mbox.name,
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
|
||||
status := imap.NewMailboxStatus(mbox.name, items)
|
||||
status.PermanentFlags = []string{
|
||||
"\\Seen", "\\Answered", "\\Flagged", "\\Deleted",
|
||||
}
|
||||
status.Flags = status.PermanentFlags
|
||||
|
||||
for _, name := range items {
|
||||
switch name {
|
||||
case imap.StatusMessages:
|
||||
count, err := mbox.backend.Storage.MailCount(mbox.user.username, mbox.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mbox.backend.Storage.MailCount: %w", err)
|
||||
}
|
||||
status.Messages = uint32(count)
|
||||
|
||||
case imap.StatusUidNext:
|
||||
id, err := mbox.backend.Storage.MailNextID(mbox.user.username, mbox.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mbox.backend.Storage.MailNextID: %w", err)
|
||||
}
|
||||
status.UidNext = uint32(id)
|
||||
|
||||
case imap.StatusUidValidity:
|
||||
status.UidValidity = 1
|
||||
|
||||
case imap.StatusRecent:
|
||||
status.Recent = 0 // TODO
|
||||
|
||||
case imap.StatusUnseen:
|
||||
unseen, err := mbox.backend.Storage.MailUnseen(mbox.user.username, mbox.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mbox.backend.Storage.MailUnseen: %w", err)
|
||||
}
|
||||
status.Unseen = uint32(unseen)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) SetSubscribed(subscribed bool) error {
|
||||
return mbox.backend.Storage.MailboxSubscribe(mbox.user.username, mbox.name, subscribed)
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
|
||||
defer close(ch)
|
||||
|
||||
ids, err := mbox.getIDsFromSeqSet(uid, seqSet)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to get IDs from sequences:", err)
|
||||
return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
mseq, mid, body, seen, answered, flagged, deleted, datetime, err := mbox.backend.Storage.MailSelect(mbox.user.username, mbox.name, int(id))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fetched := imap.NewMessage(uint32(id)+1, items)
|
||||
fetched.SeqNum = uint32(mseq)
|
||||
fetched.Uid = uint32(mid)
|
||||
|
||||
get := func() (io.Reader, textproto.Header, error) {
|
||||
bodyreader := bufio.NewReader(bytes.NewReader(body))
|
||||
hdr, err := textproto.ReadHeader(bodyreader)
|
||||
if err != nil {
|
||||
return nil, textproto.Header{}, fmt.Errorf("textproto.ReadHeader: %w", err)
|
||||
}
|
||||
return bodyreader, hdr, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
switch item {
|
||||
case imap.FetchEnvelope:
|
||||
_, hdr, err := get()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fetched.Envelope, err = backendutil.FetchEnvelope(hdr); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
case imap.FetchBody, imap.FetchBodyStructure:
|
||||
bodyreader, hdr, err := get()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fetched.BodyStructure, err = backendutil.FetchBodyStructure(hdr, bodyreader, item == imap.FetchBodyStructure); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
case imap.FetchFlags:
|
||||
fetched.Flags = []string{}
|
||||
if seen {
|
||||
fetched.Flags = append(fetched.Flags, "\\Seen")
|
||||
}
|
||||
if answered {
|
||||
fetched.Flags = append(fetched.Flags, "\\Answered")
|
||||
}
|
||||
if flagged {
|
||||
fetched.Flags = append(fetched.Flags, "\\Flagged")
|
||||
}
|
||||
if deleted {
|
||||
fetched.Flags = append(fetched.Flags, "\\Deleted")
|
||||
}
|
||||
|
||||
case imap.FetchInternalDate:
|
||||
fetched.InternalDate = datetime
|
||||
|
||||
case imap.FetchRFC822Size:
|
||||
fetched.Size = uint32(len(body))
|
||||
|
||||
case imap.FetchUid:
|
||||
fetched.Uid = uint32(id)
|
||||
|
||||
default:
|
||||
section, err := imap.ParseBodySectionName(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
bodyreader, hdr, err := get()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l, err := backendutil.FetchBodySection(hdr, bodyreader, section)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fetched.Body[section] = l
|
||||
}
|
||||
}
|
||||
|
||||
ch <- fetched
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
|
||||
return mbox.backend.Storage.MailSearch(mbox.user.username, mbox.name)
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
|
||||
b, err := ioutil.ReadAll(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("b.ReadFrom: %w", err)
|
||||
}
|
||||
id, err := mbox.backend.Storage.MailCreate(mbox.user.username, mbox.name, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mbox.backend.Storage.MailCreate: %w", err)
|
||||
}
|
||||
for _, flag := range flags {
|
||||
var seen, answered, flagged, deleted bool
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
seen = true
|
||||
case "\\Answered":
|
||||
answered = true
|
||||
case "\\Flagged":
|
||||
flagged = true
|
||||
case "\\Deleted":
|
||||
deleted = true
|
||||
}
|
||||
if err := mbox.backend.Storage.MailUpdateFlags(
|
||||
mbox.user.username, mbox.name, id, seen, answered, flagged, deleted,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqSet *imap.SeqSet, op imap.FlagsOp, flags []string) error {
|
||||
ids, err := mbox.getIDsFromSeqSet(uid, seqSet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
var seen, answered, flagged, deleted bool
|
||||
var mid int
|
||||
if op != imap.SetFlags {
|
||||
var err error
|
||||
_, mid, _, seen, answered, flagged, deleted, _, err = mbox.backend.Storage.MailSelect(mbox.user.username, mbox.name, int(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mbox.backend.Storage.MailSelect: %w", err)
|
||||
}
|
||||
}
|
||||
for _, flag := range flags {
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
seen = op != imap.RemoveFlags
|
||||
case "\\Answered":
|
||||
answered = op != imap.RemoveFlags
|
||||
case "\\Flagged":
|
||||
flagged = op != imap.RemoveFlags
|
||||
case "\\Deleted":
|
||||
deleted = op != imap.RemoveFlags
|
||||
}
|
||||
}
|
||||
|
||||
if err := mbox.backend.Storage.MailUpdateFlags(
|
||||
mbox.user.username, mbox.name, int(mid), seen, answered, flagged, deleted,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) CopyMessages(uid bool, seqSet *imap.SeqSet, destName string) error {
|
||||
ids, err := mbox.getIDsFromSeqSet(uid, seqSet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mbox.getIDsFromSeqSet: %w", err)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
_, _, body, seen, answered, flagged, deleted, _, err := mbox.backend.Storage.MailSelect(mbox.user.username, mbox.name, int(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mbox.backend.Storage.MailSelect: %w", err)
|
||||
}
|
||||
pid, err := mbox.backend.Storage.MailCreate(mbox.user.username, destName, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mbox.backend.Storage.MailCreate: %w", err)
|
||||
}
|
||||
if err = mbox.backend.Storage.MailUpdateFlags(mbox.user.username, mbox.name, pid, seen, answered, flagged, deleted); err != nil {
|
||||
return fmt.Errorf("mbox.backend.Storage.MailUpdateFlags: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Expunge() error {
|
||||
return mbox.backend.Storage.MailExpunge(mbox.user.username, mbox.name)
|
||||
}
|
||||
75
internal/imapserver/user.go
Normal file
75
internal/imapserver/user.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
backend *Backend
|
||||
username string
|
||||
}
|
||||
|
||||
func (u *User) Username() string {
|
||||
return u.username
|
||||
}
|
||||
|
||||
func (u *User) ListMailboxes(subscribed bool) (mailboxes []backend.Mailbox, err error) {
|
||||
names, err := u.backend.Storage.MailboxList(u.username, subscribed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, mailbox := range names {
|
||||
mailboxes = append(mailboxes, &Mailbox{
|
||||
backend: u.backend,
|
||||
user: u,
|
||||
name: mailbox,
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) {
|
||||
if name == "" {
|
||||
return &Mailbox{
|
||||
backend: u.backend,
|
||||
user: u,
|
||||
name: "",
|
||||
}, nil
|
||||
}
|
||||
ok, _ := u.backend.Storage.MailboxSelect(u.username, name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("mailbox %q not found", name)
|
||||
}
|
||||
return &Mailbox{
|
||||
backend: u.backend,
|
||||
user: u,
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *User) CreateMailbox(name string) error {
|
||||
return u.backend.Storage.MailboxCreate(u.username, name)
|
||||
}
|
||||
|
||||
func (u *User) DeleteMailbox(name string) error {
|
||||
if name == "INBOX" {
|
||||
return errors.New("Cannot delete INBOX")
|
||||
}
|
||||
return u.backend.Storage.MailboxDelete(u.username, name)
|
||||
}
|
||||
|
||||
func (u *User) RenameMailbox(existingName, newName string) error {
|
||||
if existingName == "INBOX" {
|
||||
return errors.New("Cannot rename INBOX")
|
||||
}
|
||||
return u.backend.Storage.MailboxRename(u.username, existingName, newName)
|
||||
}
|
||||
|
||||
func (u *User) Logout() error {
|
||||
return nil
|
||||
}
|
||||
58
internal/smtpsender/fifo.go
Normal file
58
internal/smtpsender/fifo.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package smtpsender
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type fifoQueue struct {
|
||||
frames []interface{}
|
||||
count int
|
||||
mutex sync.Mutex
|
||||
notifs chan struct{}
|
||||
}
|
||||
|
||||
func newFIFOQueue() *fifoQueue {
|
||||
q := &fifoQueue{
|
||||
notifs: make(chan struct{}),
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *fifoQueue) push(frame interface{}) bool {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
q.frames = append(q.frames, frame)
|
||||
q.count++
|
||||
select {
|
||||
case q.notifs <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *fifoQueue) pop() (interface{}, bool) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
if q.count == 0 {
|
||||
return nil, false
|
||||
}
|
||||
frame := q.frames[0]
|
||||
q.frames[0] = nil
|
||||
q.frames = q.frames[1:]
|
||||
q.count--
|
||||
if q.count == 0 {
|
||||
q.frames = nil
|
||||
}
|
||||
return frame, true
|
||||
}
|
||||
|
||||
func (q *fifoQueue) wait() <-chan struct{} {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
if q.count > 0 {
|
||||
ch := make(chan struct{})
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
return q.notifs
|
||||
}
|
||||
136
internal/smtpsender/sender.go
Normal file
136
internal/smtpsender/sender.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package smtpsender
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/neilalexander/yggmail/internal/config"
|
||||
"github.com/neilalexander/yggmail/internal/transport"
|
||||
"go.uber.org/atomic"
|
||||
)
|
||||
|
||||
type Queues struct {
|
||||
Config *config.Config
|
||||
Log *log.Logger
|
||||
Transport transport.Transport
|
||||
queues sync.Map // servername -> *Queue
|
||||
}
|
||||
|
||||
func NewQueues(config *config.Config, log *log.Logger, transport transport.Transport) *Queues {
|
||||
return &Queues{
|
||||
Config: config,
|
||||
Log: log,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func (qs *Queues) QueueFor(server string) (*Queue, error) {
|
||||
v, _ := qs.queues.LoadOrStore(server, &Queue{
|
||||
queues: qs,
|
||||
destination: server,
|
||||
fifo: newFIFOQueue(),
|
||||
})
|
||||
q, ok := v.(*Queue)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("type assertion error")
|
||||
}
|
||||
if q.running.CAS(false, true) {
|
||||
go q.run()
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
type Queue struct {
|
||||
queues *Queues
|
||||
destination string
|
||||
running atomic.Bool
|
||||
backoff atomic.Int64
|
||||
fifo *fifoQueue
|
||||
}
|
||||
|
||||
func (q *Queue) Queue(mail *QueuedMail) error {
|
||||
q.fifo.push(mail)
|
||||
if q.running.CAS(false, true) {
|
||||
go q.run()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Queue) run() {
|
||||
defer q.running.Store(false)
|
||||
for {
|
||||
select {
|
||||
case <-q.fifo.wait():
|
||||
case <-time.After(time.Second * 10):
|
||||
return
|
||||
}
|
||||
|
||||
item, ok := q.fifo.pop()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mail, ok := item.(*QueuedMail)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
q.queues.Log.Println("Processing mail from", mail.From, "to", mail.Destination)
|
||||
|
||||
if err := func() error {
|
||||
conn, err := q.queues.Transport.Dial(q.destination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("q.queues.Transport.Dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, q.destination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp.NewClient: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Hello(hex.EncodeToString(q.queues.Config.PublicKey)); err != nil {
|
||||
return fmt.Errorf("client.Hello: %w", err)
|
||||
}
|
||||
|
||||
q.backoff.Store(0)
|
||||
|
||||
if err := client.Mail(mail.From, nil); err != nil {
|
||||
return fmt.Errorf("client.Mail: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Rcpt(mail.Rcpt); err != nil {
|
||||
return fmt.Errorf("client.Rcpt: %w", err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("client.Data: %w", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
if _, err := writer.Write(mail.Content); err != nil {
|
||||
return fmt.Errorf("writer.Write: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
retry := time.Second * time.Duration(math.Exp2(float64(q.backoff.Inc())))
|
||||
q.queues.Log.Println("Queue error:", err, "- will retry in", retry)
|
||||
time.Sleep(retry)
|
||||
} else {
|
||||
q.queues.Log.Println("Sent mail from", mail.From, "to", mail.Destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type QueuedMail struct {
|
||||
From string // mail address
|
||||
Rcpt string // mail addresses
|
||||
Destination string // server name
|
||||
Content []byte
|
||||
}
|
||||
79
internal/smtpserver/backend.go
Normal file
79
internal/smtpserver/backend.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/neilalexander/yggmail/internal/config"
|
||||
"github.com/neilalexander/yggmail/internal/smtpsender"
|
||||
"github.com/neilalexander/yggmail/internal/storage"
|
||||
)
|
||||
|
||||
type BackendMode int
|
||||
|
||||
const (
|
||||
BackendModeInternal BackendMode = iota
|
||||
BackendModeExternal
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
Mode BackendMode
|
||||
Log *log.Logger
|
||||
Config *config.Config
|
||||
Queues *smtpsender.Queues
|
||||
Storage storage.Storage
|
||||
}
|
||||
|
||||
func (b *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||
switch b.Mode {
|
||||
case BackendModeInternal:
|
||||
// The connection came from our local listener
|
||||
if authed, err := b.Storage.TryAuthenticate(username, password); err != nil {
|
||||
b.Log.Printf("Failed to authenticate SMTP user %q due to error: %s", username, err)
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||
} else if !authed {
|
||||
b.Log.Printf("Failed to authenticate SMTP user %q\n", username)
|
||||
return nil, smtp.ErrAuthRequired
|
||||
}
|
||||
defer b.Log.Printf("Authenticated SMTP user %q\n", username)
|
||||
return &SessionLocal{
|
||||
backend: b,
|
||||
state: state,
|
||||
}, nil
|
||||
|
||||
case BackendModeExternal:
|
||||
return nil, fmt.Errorf("Not expecting authenticated connection on external backend")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Authenticated login failed")
|
||||
}
|
||||
|
||||
func (b *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||
switch b.Mode {
|
||||
case BackendModeInternal:
|
||||
return nil, fmt.Errorf("Not expecting anonymous connection on internal backend")
|
||||
|
||||
case BackendModeExternal:
|
||||
// The connection came from our overlay listener, so we should check
|
||||
// that they are who they claim to be
|
||||
if state.Hostname != state.RemoteAddr.String() {
|
||||
return nil, fmt.Errorf("You are not who you claim to be")
|
||||
}
|
||||
|
||||
pks, err := hex.DecodeString(state.RemoteAddr.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hex.DecodeString: %w", err)
|
||||
}
|
||||
|
||||
b.Log.Println("Incoming SMTP session from", state.RemoteAddr.String())
|
||||
return &SessionRemote{
|
||||
backend: b,
|
||||
state: state,
|
||||
public: pks[:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Anonymous login failed")
|
||||
}
|
||||
14
internal/smtpserver/session.go
Normal file
14
internal/smtpserver/session.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseAddress(email string) (string, string, error) {
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at == 0 {
|
||||
return "", "", fmt.Errorf("invalid email address")
|
||||
}
|
||||
return email[:at], email[at+1:], nil
|
||||
}
|
||||
110
internal/smtpserver/session_local.go
Normal file
110
internal/smtpserver/session_local.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/neilalexander/yggmail/internal/smtpsender"
|
||||
)
|
||||
|
||||
type SessionLocal struct {
|
||||
backend *Backend
|
||||
state *smtp.ConnectionState
|
||||
from string
|
||||
rcpt []string
|
||||
}
|
||||
|
||||
func (s *SessionLocal) Mail(from string, opts smtp.MailOptions) error {
|
||||
_, host, err := parseAddress(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parseAddress: %w", err)
|
||||
}
|
||||
|
||||
if host != hex.EncodeToString(s.backend.Config.PublicKey) {
|
||||
return fmt.Errorf("not allowed to send outgoing mail as %s", from)
|
||||
}
|
||||
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionLocal) Rcpt(to string) error {
|
||||
s.rcpt = append(s.rcpt, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionLocal) Data(r io.Reader) error {
|
||||
m, err := message.Read(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("message.Read: %w", err)
|
||||
}
|
||||
|
||||
m.Header.Add(
|
||||
"Received", fmt.Sprintf("from %s by Yggmail %s; %s",
|
||||
s.state.RemoteAddr.String(),
|
||||
hex.EncodeToString(s.backend.Config.PublicKey),
|
||||
time.Now().String(),
|
||||
),
|
||||
)
|
||||
|
||||
servers := make(map[string]struct{})
|
||||
|
||||
for _, rcpt := range s.rcpt {
|
||||
localpart, host, err := parseAddress(rcpt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parseAddress: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := servers[host]; ok {
|
||||
continue
|
||||
}
|
||||
servers[host] = struct{}{}
|
||||
|
||||
if host == hex.EncodeToString(s.backend.Config.PublicKey) {
|
||||
var b bytes.Buffer
|
||||
if err := m.WriteTo(&b); err != nil {
|
||||
return fmt.Errorf("m.WriteTo: %w", err)
|
||||
}
|
||||
if _, err := s.backend.Storage.MailCreate(localpart, "INBOX", b.Bytes()); err != nil {
|
||||
return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
queue, err := s.backend.Queues.QueueFor(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s.backend.Queues.QueueFor: %w", err)
|
||||
}
|
||||
|
||||
mail := &smtpsender.QueuedMail{
|
||||
From: s.from,
|
||||
Rcpt: rcpt,
|
||||
Destination: host,
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := m.WriteTo(&b); err != nil {
|
||||
return fmt.Errorf("m.WriteTo: %w", err)
|
||||
}
|
||||
mail.Content = b.Bytes()
|
||||
|
||||
if err := queue.Queue(mail); err != nil {
|
||||
return fmt.Errorf("queue.Queue: %w", err)
|
||||
}
|
||||
|
||||
s.backend.Log.Println("Queued mail for", mail.Destination)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionLocal) Reset() {}
|
||||
|
||||
func (s *SessionLocal) Logout() error {
|
||||
return nil
|
||||
}
|
||||
82
internal/smtpserver/session_remote.go
Normal file
82
internal/smtpserver/session_remote.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
type SessionRemote struct {
|
||||
backend *Backend
|
||||
state *smtp.ConnectionState
|
||||
public ed25519.PublicKey
|
||||
from string
|
||||
localparts []string
|
||||
}
|
||||
|
||||
func (s *SessionRemote) Mail(from string, opts smtp.MailOptions) error {
|
||||
_, host, err := parseAddress(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail.ParseAddress: %w", err)
|
||||
}
|
||||
|
||||
if local := s.state.RemoteAddr.String(); local != host {
|
||||
return fmt.Errorf("not allowed to send incoming mail as %s", from)
|
||||
}
|
||||
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionRemote) Rcpt(to string) error {
|
||||
user, host, err := parseAddress(to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail.ParseAddress: %w", err)
|
||||
}
|
||||
|
||||
if local := hex.EncodeToString(s.backend.Config.PublicKey); host != local {
|
||||
return fmt.Errorf("not allowed to send mail to %q", host)
|
||||
}
|
||||
|
||||
s.localparts = append(s.localparts, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionRemote) Data(r io.Reader) error {
|
||||
m, err := message.Read(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("message.Read: %w", err)
|
||||
}
|
||||
|
||||
m.Header.Add(
|
||||
"Received", fmt.Sprintf("from Yggmail %s; %s",
|
||||
hex.EncodeToString(s.public),
|
||||
time.Now().String(),
|
||||
),
|
||||
)
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := m.WriteTo(&b); err != nil {
|
||||
return fmt.Errorf("m.WriteTo: %w", err)
|
||||
}
|
||||
|
||||
for _, localpart := range s.localparts {
|
||||
if _, err := s.backend.Storage.MailCreate(localpart, "INBOX", b.Bytes()); err != nil {
|
||||
return fmt.Errorf("s.backend.Storage.StoreMessageFor: %w", err)
|
||||
}
|
||||
s.backend.Log.Printf("Stored new mail for local user %q from %s", localpart, s.from)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionRemote) Reset() {}
|
||||
|
||||
func (s *SessionRemote) Logout() error {
|
||||
return nil
|
||||
}
|
||||
18
internal/smtpserver/smtp.go
Normal file
18
internal/smtpserver/smtp.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
type SMTPServer struct {
|
||||
server *smtp.Server
|
||||
backend smtp.Backend
|
||||
}
|
||||
|
||||
func NewSMTPServer(backend smtp.Backend) *SMTPServer {
|
||||
s := &SMTPServer{
|
||||
server: smtp.NewServer(backend),
|
||||
backend: backend,
|
||||
}
|
||||
return s
|
||||
}
|
||||
40
internal/storage/sqlite3/sqlite3.go
Normal file
40
internal/storage/sqlite3/sqlite3.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package sqlite3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type SQLite3Storage struct {
|
||||
*TableConfig
|
||||
*TableUsers
|
||||
*TableMailboxes
|
||||
*TableMails
|
||||
}
|
||||
|
||||
func NewSQLite3StorageStorage(filename string) (*SQLite3Storage, error) {
|
||||
db, err := sql.Open("sqlite3", "file:"+filename+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sql.Open: %w", err)
|
||||
}
|
||||
s := &SQLite3Storage{}
|
||||
s.TableConfig, err = NewTableConfig(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewTableConfig: %w", err)
|
||||
}
|
||||
s.TableUsers, err = NewTableUsers(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewTableUsers: %w", err)
|
||||
}
|
||||
s.TableMailboxes, err = NewTableMailboxes(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewTableMailboxes: %w", err)
|
||||
}
|
||||
s.TableMails, err = NewTableMails(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewTableMails: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
61
internal/storage/sqlite3/table_config.go
Normal file
61
internal/storage/sqlite3/table_config.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package sqlite3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type TableConfig struct {
|
||||
db *sql.DB
|
||||
get *sql.Stmt
|
||||
set *sql.Stmt
|
||||
}
|
||||
|
||||
const configSchema = `
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY(key)
|
||||
);
|
||||
`
|
||||
|
||||
const configGet = `
|
||||
SELECT value FROM config WHERE key = $1
|
||||
`
|
||||
|
||||
const configSet = `
|
||||
INSERT OR REPLACE INTO config (key, value) VALUES($1, $2)
|
||||
`
|
||||
|
||||
func NewTableConfig(db *sql.DB) (*TableConfig, error) {
|
||||
t := &TableConfig{
|
||||
db: db,
|
||||
}
|
||||
_, err := db.Exec(configSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Exec: %w", err)
|
||||
}
|
||||
t.get, err = db.Prepare(configGet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(get): %w", err)
|
||||
}
|
||||
t.set, err = db.Prepare(configSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(set): %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *TableConfig) ConfigGet(key string) (string, error) {
|
||||
var value string
|
||||
err := t.get.QueryRow(key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (t *TableConfig) ConfigSet(key, value string) error {
|
||||
_, err := t.set.Exec(key, value)
|
||||
return err
|
||||
}
|
||||
152
internal/storage/sqlite3/table_mailboxes.go
Normal file
152
internal/storage/sqlite3/table_mailboxes.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package sqlite3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type TableMailboxes struct {
|
||||
db *sql.DB
|
||||
selectMailboxes *sql.Stmt
|
||||
listMailboxes *sql.Stmt
|
||||
listMailboxesSubscribed *sql.Stmt
|
||||
createMailbox *sql.Stmt
|
||||
renameMailbox *sql.Stmt
|
||||
deleteMailbox *sql.Stmt
|
||||
subscribeMailbox *sql.Stmt
|
||||
}
|
||||
|
||||
const mailboxesSchema = `
|
||||
CREATE TABLE IF NOT EXISTS mailboxes (
|
||||
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
mailbox TEXT NOT NULL DEFAULT('INBOX'),
|
||||
subscribed BOOLEAN NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(username, mailbox)
|
||||
);
|
||||
`
|
||||
|
||||
const mailboxesList = `
|
||||
SELECT mailbox FROM mailboxes WHERE username = $1
|
||||
`
|
||||
|
||||
const mailboxesListSubscribed = `
|
||||
SELECT mailbox FROM mailboxes WHERE username = $1 AND subscribed = 1
|
||||
`
|
||||
|
||||
const mailboxesSelect = `
|
||||
SELECT mailbox FROM mailboxes WHERE username = $1 AND mailbox = $2
|
||||
`
|
||||
|
||||
const mailboxesCreate = `
|
||||
INSERT INTO mailboxes (username, mailbox) VALUES($1, $2)
|
||||
`
|
||||
|
||||
const mailboxesRename = `
|
||||
UPDATE mailboxes SET mailbox = $1 WHERE username = $2 AND mailbox = $3
|
||||
`
|
||||
|
||||
const mailboxesDelete = `
|
||||
DELETE FROM mailboxes WHERE username = $1 AND mailbox = $2
|
||||
`
|
||||
|
||||
const mailboxesSubscribe = `
|
||||
UPDATE mailboxes SET subscribed = $1 WHERE username = $2 AND mailbox = $3
|
||||
`
|
||||
|
||||
func NewTableMailboxes(db *sql.DB) (*TableMailboxes, error) {
|
||||
t := &TableMailboxes{
|
||||
db: db,
|
||||
}
|
||||
_, err := db.Exec(mailboxesSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Exec: %w", err)
|
||||
}
|
||||
t.listMailboxes, err = db.Prepare(mailboxesList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err)
|
||||
}
|
||||
t.listMailboxesSubscribed, err = db.Prepare(mailboxesListSubscribed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err)
|
||||
}
|
||||
t.selectMailboxes, err = db.Prepare(mailboxesSelect)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesSelect): %w", err)
|
||||
}
|
||||
t.createMailbox, err = db.Prepare(mailboxesCreate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesCreate): %w", err)
|
||||
}
|
||||
t.deleteMailbox, err = db.Prepare(mailboxesDelete)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesDelete): %w", err)
|
||||
}
|
||||
t.renameMailbox, err = db.Prepare(mailboxesRename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesRename): %w", err)
|
||||
}
|
||||
t.subscribeMailbox, err = db.Prepare(mailboxesSubscribe)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(mailboxesSubscribe): %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *TableMailboxes) MailboxList(user string, onlySubscribed bool) ([]string, error) {
|
||||
stmt := t.listMailboxes
|
||||
if onlySubscribed {
|
||||
stmt = t.listMailboxesSubscribed
|
||||
}
|
||||
rows, err := stmt.Query(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("t.listMailboxes.Query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var mailboxes []string
|
||||
for rows.Next() {
|
||||
var mailbox string
|
||||
if err := rows.Scan(&mailbox); err != nil {
|
||||
return nil, fmt.Errorf("rows.Scan: %w", err)
|
||||
}
|
||||
mailboxes = append(mailboxes, mailbox)
|
||||
}
|
||||
return mailboxes, nil
|
||||
}
|
||||
|
||||
func (t *TableMailboxes) MailboxSelect(user, mailbox string) (bool, error) {
|
||||
row := t.selectMailboxes.QueryRow(user, mailbox)
|
||||
if err := row.Err(); err != nil && err != sql.ErrNoRows {
|
||||
return false, fmt.Errorf("row.Err: %w", err)
|
||||
} else if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
var got string
|
||||
if err := row.Scan(&got); err != nil {
|
||||
return false, fmt.Errorf("row.Scan: %w", err)
|
||||
}
|
||||
return mailbox == got, nil
|
||||
}
|
||||
|
||||
func (t *TableMailboxes) MailboxCreate(user, name string) error {
|
||||
_, err := t.createMailbox.Exec(user, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TableMailboxes) MailboxRename(user, old, new string) error {
|
||||
_, err := t.renameMailbox.Exec(new, user, old)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TableMailboxes) MailboxDelete(user, name string) error {
|
||||
_, err := t.deleteMailbox.Exec(user, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TableMailboxes) MailboxSubscribe(user, name string, subscribed bool) error {
|
||||
sn := 1
|
||||
if !subscribed {
|
||||
sn = 0
|
||||
}
|
||||
_, err := t.subscribeMailbox.Exec(sn, user, name)
|
||||
return err
|
||||
}
|
||||
227
internal/storage/sqlite3/table_mails.go
Normal file
227
internal/storage/sqlite3/table_mails.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package sqlite3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TableMails struct {
|
||||
db *sql.DB
|
||||
selectMails *sql.Stmt
|
||||
selectMail *sql.Stmt
|
||||
selectMailNextID *sql.Stmt
|
||||
selectIDForSeq *sql.Stmt
|
||||
searchMail *sql.Stmt
|
||||
createMail *sql.Stmt
|
||||
countMails *sql.Stmt
|
||||
countUnseenMails *sql.Stmt
|
||||
updateMailFlags *sql.Stmt
|
||||
deleteMail *sql.Stmt
|
||||
expungeMail *sql.Stmt
|
||||
}
|
||||
|
||||
const mailsSchema = `
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
username TEXT NOT NULL,
|
||||
mailbox TEXT NOT NULL,
|
||||
id INTEGER NOT NULL DEFAULT 1,
|
||||
mail BLOB NOT NULL,
|
||||
datetime INTEGER NOT NULL,
|
||||
seen BOOLEAN NOT NULL DEFAULT 0, -- the mail has been read
|
||||
answered BOOLEAN NOT NULL DEFAULT 0, -- the mail has been replied to
|
||||
flagged BOOLEAN NOT NULL DEFAULT 0, -- the mail has been flagged for later attention
|
||||
deleted BOOLEAN NOT NULL DEFAULT 0, -- the email is marked for deletion at next EXPUNGE
|
||||
PRIMARY KEY(username, mailbox, id),
|
||||
FOREIGN KEY (username, mailbox) REFERENCES mailboxes(username, mailbox) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
DROP VIEW IF EXISTS inboxes;
|
||||
CREATE VIEW IF NOT EXISTS inboxes AS SELECT * FROM (
|
||||
SELECT ROW_NUMBER() OVER (PARTITION BY username, mailbox) AS seq, * FROM mails
|
||||
)
|
||||
ORDER BY username, mailbox, id;
|
||||
`
|
||||
|
||||
const selectMailsStmt = `
|
||||
SELECT * FROM inboxes
|
||||
ORDER BY username, mailbox, id
|
||||
`
|
||||
|
||||
const selectMailStmt = `
|
||||
SELECT seq, id, mail, datetime, seen, answered, flagged, deleted FROM inboxes
|
||||
WHERE username = $1 AND mailbox = $2 AND id = $3
|
||||
ORDER BY username, mailbox, id
|
||||
`
|
||||
|
||||
const selectMailCountStmt = `
|
||||
SELECT COUNT(*) FROM mails WHERE username = $1 AND mailbox = $2
|
||||
`
|
||||
|
||||
const selectMailUnseenStmt = `
|
||||
SELECT COUNT(*) FROM mails WHERE username = $1 AND mailbox = $2 AND seen = 0
|
||||
`
|
||||
|
||||
const searchMailStmt = `
|
||||
SELECT id FROM mails
|
||||
WHERE username = $1 AND mailbox = $2
|
||||
ORDER BY username, mailbox, id
|
||||
`
|
||||
|
||||
const insertMailStmt = `
|
||||
INSERT INTO mails (username, mailbox, id, mail, datetime) VALUES(
|
||||
$1, $2, (
|
||||
SELECT IFNULL(MAX(id)+1,1) AS id FROM mails
|
||||
WHERE username = $1 AND mailbox = $2
|
||||
), $3, $4
|
||||
)
|
||||
RETURNING id;
|
||||
`
|
||||
|
||||
const selectIDForSeqStmt = `
|
||||
SELECT id FROM inboxes
|
||||
WHERE username = $1 AND mailbox = $2 AND seq = $3
|
||||
`
|
||||
|
||||
const selectMailNextID = `
|
||||
SELECT IFNULL(MAX(id)+1,1) AS id FROM mails
|
||||
WHERE username = $1 AND mailbox = $2
|
||||
`
|
||||
|
||||
const updateMailFlagsStmt = `
|
||||
UPDATE mails SET seen = $1, answered = $2, flagged = $3, deleted = $4 WHERE username = $5 AND mailbox = $6 AND id = $7
|
||||
`
|
||||
|
||||
const deleteMailStmt = `
|
||||
UPDATE mails SET deleted = 1 WHERE username = $1 AND mailbox = $2 AND id = $3
|
||||
`
|
||||
|
||||
const expungeMailStmt = `
|
||||
DELETE FROM mails WHERE username = $1 AND mailbox = $2 AND deleted = 1
|
||||
`
|
||||
|
||||
func NewTableMails(db *sql.DB) (*TableMails, error) {
|
||||
t := &TableMails{
|
||||
db: db,
|
||||
}
|
||||
_, err := db.Exec(mailsSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Exec: %w", err)
|
||||
}
|
||||
t.selectMails, err = db.Prepare(selectMailsStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectMailsStmt): %w", err)
|
||||
}
|
||||
t.selectMail, err = db.Prepare(selectMailStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectMailStmt): %w", err)
|
||||
}
|
||||
t.selectMailNextID, err = db.Prepare(selectMailNextID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectMailNextID): %w", err)
|
||||
}
|
||||
t.selectIDForSeq, err = db.Prepare(selectIDForSeqStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err)
|
||||
}
|
||||
t.searchMail, err = db.Prepare(searchMailStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectPIDForIDStmt): %w", err)
|
||||
}
|
||||
t.createMail, err = db.Prepare(insertMailStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(insertMailStmt): %w", err)
|
||||
}
|
||||
t.updateMailFlags, err = db.Prepare(updateMailFlagsStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(updateMailSeenStmt): %w", err)
|
||||
}
|
||||
t.deleteMail, err = db.Prepare(deleteMailStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(deleteMailStmt): %w", err)
|
||||
}
|
||||
t.expungeMail, err = db.Prepare(expungeMailStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(expungeMailStmt): %w", err)
|
||||
}
|
||||
t.countMails, err = db.Prepare(selectMailCountStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectMailCountStmt): %w", err)
|
||||
}
|
||||
t.countUnseenMails, err = db.Prepare(selectMailUnseenStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(selectMailUnseenStmt): %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *TableMails) MailCreate(user, mailbox string, data []byte) (int, error) {
|
||||
var id int
|
||||
err := t.createMail.QueryRow(user, mailbox, data, time.Now().Unix()).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailSelect(user, mailbox string, id int) (int, int, []byte, bool, bool, bool, bool, time.Time, error) {
|
||||
var data []byte
|
||||
var seen, answered, flagged, deleted bool
|
||||
var ts int64
|
||||
var seq, pid int
|
||||
err := t.selectMail.QueryRow(user, mailbox, id).Scan(&seq, &pid, &data, &ts, &seen, &answered, &flagged, &deleted)
|
||||
return seq, pid, data, seen, answered, flagged, deleted, time.Unix(ts, 0), err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailSearch(user, mailbox string) ([]uint32, error) {
|
||||
var ids []uint32
|
||||
rows, err := t.searchMail.Query(user, mailbox)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("t.searchMail.Query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("rows.Scan: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (t *TableMails) MailNextID(user, mailbox string) (int, error) {
|
||||
var id int
|
||||
err := t.selectMailNextID.QueryRow(user, mailbox).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailIDForSeq(user, mailbox string, seq int) (int, error) {
|
||||
var id int
|
||||
err := t.selectIDForSeq.QueryRow(user, mailbox, seq).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailUnseen(user, mailbox string) (int, error) {
|
||||
var unseen int
|
||||
err := t.countUnseenMails.QueryRow(user, mailbox).Scan(&unseen)
|
||||
return unseen, err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailUpdateFlags(user, mailbox string, id int, seen, answered, flagged, deleted bool) error {
|
||||
_, err := t.updateMailFlags.Exec(seen, answered, flagged, deleted, user, mailbox, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailDelete(user, mailbox, id string) error {
|
||||
_, err := t.deleteMail.Exec(user, mailbox, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailExpunge(user, mailbox string) error {
|
||||
_, err := t.expungeMail.Exec(user, mailbox)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TableMails) MailCount(user, mailbox string) (int, error) {
|
||||
var count int
|
||||
err := t.countMails.QueryRow(user, mailbox).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
76
internal/storage/sqlite3/table_users.go
Normal file
76
internal/storage/sqlite3/table_users.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package sqlite3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type TableUsers struct {
|
||||
db *sql.DB
|
||||
getPassword *sql.Stmt
|
||||
insertUser *sql.Stmt
|
||||
}
|
||||
|
||||
const usersSchema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
PRIMARY KEY(username)
|
||||
);
|
||||
`
|
||||
|
||||
const usersGetPassword = `
|
||||
SELECT password FROM users WHERE username = $1
|
||||
`
|
||||
|
||||
const usersInsertUser = `
|
||||
INSERT INTO users (username, password) VALUES($1, $2)
|
||||
`
|
||||
|
||||
func NewTableUsers(db *sql.DB) (*TableUsers, error) {
|
||||
t := &TableUsers{
|
||||
db: db,
|
||||
}
|
||||
_, err := db.Exec(usersSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Exec: %w", err)
|
||||
}
|
||||
t.getPassword, err = db.Prepare(usersGetPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(getPassword): %w", err)
|
||||
}
|
||||
t.insertUser, err = db.Prepare(usersInsertUser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Prepare(usersInsertUser): %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *TableUsers) CreateUser(username, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bcrypt.GenerateFromPassword: %w", err)
|
||||
}
|
||||
if _, err := t.insertUser.Exec(username, hash); err != nil {
|
||||
return fmt.Errorf("t.insertUser.Exec: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TableUsers) TryAuthenticate(username, password string) (bool, error) {
|
||||
var dbPasswordHash []byte
|
||||
err := t.getPassword.QueryRow(username).Scan(&dbPasswordHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
err = bcrypt.CompareHashAndPassword(dbPasswordHash, []byte(password))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
28
internal/storage/storage.go
Normal file
28
internal/storage/storage.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package storage
|
||||
|
||||
import "time"
|
||||
|
||||
type Storage interface {
|
||||
ConfigGet(key string) (string, error)
|
||||
ConfigSet(key, value string) error
|
||||
|
||||
TryAuthenticate(username, password string) (bool, error)
|
||||
|
||||
MailboxSelect(user, mailbox string) (bool, error)
|
||||
MailNextID(user, mailbox string) (int, error)
|
||||
MailIDForSeq(user, mailbox string, id int) (int, error)
|
||||
MailUnseen(user, mailbox string) (int, error)
|
||||
MailboxList(user string, onlySubscribed bool) ([]string, error)
|
||||
MailboxCreate(user, name string) error
|
||||
MailboxRename(user, old, new string) error
|
||||
MailboxDelete(user, name string) error
|
||||
MailboxSubscribe(user, name string, subscribed bool) error
|
||||
|
||||
MailCreate(user, mailbox string, data []byte) (int, error)
|
||||
MailSelect(user, mailbox string, id int) (int, int, []byte, bool, bool, bool, bool, time.Time, error)
|
||||
MailSearch(user, mailbox string) ([]uint32, error)
|
||||
MailUpdateFlags(user, mailbox string, id int, seen, answered, flagged, deleted bool) error
|
||||
MailDelete(user, mailbox, id string) error
|
||||
MailExpunge(user, mailbox string) error
|
||||
MailCount(user, mailbox string) (int, error)
|
||||
}
|
||||
10
internal/transport/transport.go
Normal file
10
internal/transport/transport.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
type Transport interface {
|
||||
Dial(host string) (net.Conn, error)
|
||||
Listener() net.Listener
|
||||
}
|
||||
76
internal/transport/yggdrasil.go
Normal file
76
internal/transport/yggdrasil.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
iwt "github.com/Arceliar/ironwood/types"
|
||||
gologme "github.com/gologme/log"
|
||||
"github.com/neilalexander/utp"
|
||||
"github.com/yggdrasil-network/yggdrasil-go/src/config"
|
||||
"github.com/yggdrasil-network/yggdrasil-go/src/core"
|
||||
"github.com/yggdrasil-network/yggdrasil-go/src/multicast"
|
||||
)
|
||||
|
||||
type YggdrasilTransport struct {
|
||||
Sessions *utp.Socket
|
||||
}
|
||||
|
||||
func NewYggdrasilTransport(log *log.Logger, sk ed25519.PrivateKey, pk ed25519.PublicKey, peer string) (*YggdrasilTransport, error) {
|
||||
config := &config.NodeConfig{
|
||||
PublicKey: hex.EncodeToString(pk),
|
||||
PrivateKey: hex.EncodeToString(sk),
|
||||
MulticastInterfaces: []config.MulticastInterfaceConfig{
|
||||
{
|
||||
Regex: ".*",
|
||||
Beacon: true,
|
||||
Listen: true,
|
||||
},
|
||||
},
|
||||
NodeInfo: map[string]interface{}{
|
||||
"name": "Yggmail",
|
||||
},
|
||||
}
|
||||
if peer != "" {
|
||||
config.Peers = append(config.Peers, peer)
|
||||
}
|
||||
glog := gologme.New(log.Writer(), "[ \033[33mYggdrasil\033[0m ] ", 0)
|
||||
glog.EnableLevel("warn")
|
||||
glog.EnableLevel("error")
|
||||
glog.EnableLevel("info")
|
||||
core := &core.Core{}
|
||||
if err := core.Start(config, glog); err != nil {
|
||||
return nil, fmt.Errorf("core.Start: %w", err)
|
||||
}
|
||||
multicast := &multicast.Multicast{}
|
||||
if err := multicast.Init(core, config, glog, nil); err != nil {
|
||||
return nil, fmt.Errorf("multicast.Init: %w", err)
|
||||
}
|
||||
if err := multicast.Start(); err != nil {
|
||||
return nil, fmt.Errorf("multicast.Start: %w", err)
|
||||
}
|
||||
us, err := utp.NewSocketFromPacketConnNoClose(core)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("utp.NewSocketFromPacketConnNoClose: %w", err)
|
||||
}
|
||||
return &YggdrasilTransport{
|
||||
Sessions: us,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *YggdrasilTransport) Dial(host string) (net.Conn, error) {
|
||||
addr := make(iwt.Addr, ed25519.PublicKeySize)
|
||||
k, err := hex.DecodeString(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(addr, k)
|
||||
return t.Sessions.DialAddr(addr)
|
||||
}
|
||||
|
||||
func (t *YggdrasilTransport) Listener() net.Listener {
|
||||
return t.Sessions
|
||||
}
|
||||
Reference in New Issue
Block a user