initial
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// Package imap provides an IMAP4rev2 server backed by the encrypted SQLite store.
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/auth"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/config"
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
)
|
||||
|
||||
// Deps groups dependencies needed by the IMAP server.
|
||||
type Deps struct {
|
||||
DB *db.DB
|
||||
Crypt *appCrypto.Crypto
|
||||
Brute *auth.BruteGuard
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// NewServer creates an IMAP4rev2 server for port 143 (STARTTLS).
|
||||
func NewServer(d *Deps, tlsCfg *tls.Config) *imapserver.Server {
|
||||
return imapserver.New(&imapserver.Options{
|
||||
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
clientIP := connRemoteIP(c)
|
||||
if d.Brute != nil && d.Cfg.BruteMaxTries > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
if banned, err := d.Brute.IsBanned(ctx, clientIP); err == nil && banned {
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("connection refused")
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
return &IMAPSession{deps: d, clientIP: clientIP}, &imapserver.GreetingData{}, nil
|
||||
},
|
||||
Caps: imap.CapSet{
|
||||
imap.CapIMAP4rev2: {},
|
||||
imap.CapIMAP4rev1: {},
|
||||
},
|
||||
TLSConfig: tlsCfg,
|
||||
InsecureAuth: tlsCfg == nil,
|
||||
})
|
||||
}
|
||||
|
||||
// ListenAndServe listens on addr (port 143 / STARTTLS).
|
||||
func ListenAndServe(s *imapserver.Server, addr, name string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s listen %s: %w", name, addr, err)
|
||||
}
|
||||
log.Printf("[%s] listening on %s", name, addr)
|
||||
return s.Serve(ln)
|
||||
}
|
||||
|
||||
// ListenAndServeTLS listens with implicit TLS (port 993 / IMAPS).
|
||||
func ListenAndServeTLS(s *imapserver.Server, addr, name string) error {
|
||||
log.Printf("[%s] listening on %s (TLS)", name, addr)
|
||||
return s.ListenAndServeTLS(addr)
|
||||
}
|
||||
|
||||
// connRemoteIP returns the client IP from an imapserver.Conn.
|
||||
func connRemoteIP(c *imapserver.Conn) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
nc := c.NetConn()
|
||||
if nc == nil {
|
||||
return ""
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(nc.RemoteAddr().String())
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/emersion/go-message/textproto"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
const mailboxDelim rune = '/'
|
||||
|
||||
// msgEntry holds the in-memory descriptor for one message in the selected mailbox.
|
||||
type msgEntry struct {
|
||||
dbID int64
|
||||
uid imap.UID
|
||||
isRead bool
|
||||
isStarred bool
|
||||
isDraft bool
|
||||
isDeleted bool
|
||||
extraFlags string
|
||||
size int64
|
||||
internalDate time.Time
|
||||
}
|
||||
|
||||
func (e *msgEntry) flagList() []imap.Flag {
|
||||
var flags []imap.Flag
|
||||
if e.isRead {
|
||||
flags = append(flags, imap.FlagSeen)
|
||||
}
|
||||
if e.isStarred {
|
||||
flags = append(flags, imap.FlagFlagged)
|
||||
}
|
||||
if e.isDraft {
|
||||
flags = append(flags, imap.FlagDraft)
|
||||
}
|
||||
if e.isDeleted {
|
||||
flags = append(flags, imap.FlagDeleted)
|
||||
}
|
||||
for _, f := range strings.Fields(e.extraFlags) {
|
||||
flags = append(flags, imap.Flag(f))
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// IMAPSession implements imapserver.SessionIMAP4rev2.
|
||||
type IMAPSession struct {
|
||||
deps *Deps
|
||||
clientIP string
|
||||
user *models.User // set after Login
|
||||
|
||||
selectedMailbox *models.Mailbox
|
||||
msgs []msgEntry // index+1 = IMAP sequence number
|
||||
mboxTracker *imapserver.MailboxTracker
|
||||
sessionTracker *imapserver.SessionTracker
|
||||
}
|
||||
|
||||
var _ imapserver.SessionIMAP4rev2 = (*IMAPSession)(nil)
|
||||
|
||||
// ---- Authentication ----
|
||||
|
||||
func (s *IMAPSession) Close() error {
|
||||
if s.sessionTracker != nil {
|
||||
s.sessionTracker.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Login(username, password string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := s.deps.DB.GetUserByEmail(ctx, username)
|
||||
if err != nil {
|
||||
log.Printf("[imap] login error %s: %v", username, err)
|
||||
return imapserver.ErrAuthFailed
|
||||
}
|
||||
if user == nil || !user.Enabled {
|
||||
s.recordAttempt(ctx, username, false)
|
||||
return imapserver.ErrAuthFailed
|
||||
}
|
||||
if err := crypto.CheckPassword(user.PasswordHash, password); err != nil {
|
||||
log.Printf("[imap] auth failed %s from %s", username, s.clientIP)
|
||||
s.recordAttempt(ctx, username, false)
|
||||
return imapserver.ErrAuthFailed
|
||||
}
|
||||
|
||||
s.user = user
|
||||
s.deps.DB.UpdateLastLogin(ctx, user.ID) //nolint:errcheck
|
||||
s.recordAttempt(ctx, username, true)
|
||||
log.Printf("[imap] auth OK %s from %s", username, s.clientIP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) recordAttempt(ctx context.Context, email string, success bool) {
|
||||
if s.deps.Brute != nil {
|
||||
s.deps.Brute.RecordAttempt(ctx, s.clientIP, email, success) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mailbox management ----
|
||||
|
||||
func (s *IMAPSession) Select(mailboxName string, options *imap.SelectOptions) (*imap.SelectData, error) {
|
||||
s.doUnselect()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mbox, err := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName)
|
||||
if err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
if mbox == nil {
|
||||
return nil, noSuchMailbox()
|
||||
}
|
||||
|
||||
msgs, err := s.deps.DB.ListIMAPMessages(ctx, mbox.ID)
|
||||
if err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
|
||||
s.selectedMailbox = mbox
|
||||
s.msgs = make([]msgEntry, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
s.msgs = append(s.msgs, msgEntry{
|
||||
dbID: m.ID,
|
||||
uid: imap.UID(m.UID),
|
||||
isRead: m.IsRead,
|
||||
isStarred: m.IsStarred,
|
||||
isDraft: m.IsDraft,
|
||||
extraFlags: m.Flags,
|
||||
size: m.SizeBytes,
|
||||
internalDate: m.ReceivedAt,
|
||||
})
|
||||
}
|
||||
|
||||
s.mboxTracker = imapserver.NewMailboxTracker(uint32(len(s.msgs)))
|
||||
s.sessionTracker = s.mboxTracker.NewSession()
|
||||
|
||||
var firstUnseen uint32
|
||||
for i, m := range s.msgs {
|
||||
if !m.isRead {
|
||||
firstUnseen = uint32(i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &imap.SelectData{
|
||||
Flags: allFlags(),
|
||||
PermanentFlags: allFlags(),
|
||||
NumMessages: uint32(len(s.msgs)),
|
||||
FirstUnseenSeqNum: firstUnseen,
|
||||
UIDNext: imap.UID(mbox.UIDNext),
|
||||
UIDValidity: mbox.UIDValidity,
|
||||
List: &imap.ListData{
|
||||
Mailbox: mailboxName,
|
||||
Delim: mailboxDelim,
|
||||
Attrs: mboxAttrs(mbox),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Unselect() error {
|
||||
s.doUnselect()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) doUnselect() {
|
||||
if s.sessionTracker != nil {
|
||||
s.sessionTracker.Close()
|
||||
s.sessionTracker = nil
|
||||
}
|
||||
s.selectedMailbox = nil
|
||||
s.msgs = nil
|
||||
s.mboxTracker = nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Create(mailboxName string, options *imap.CreateOptions) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
existing, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName)
|
||||
if existing != nil {
|
||||
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeAlreadyExists, Text: "Mailbox already exists"}
|
||||
}
|
||||
_, err := s.deps.DB.CreateMailbox(ctx, s.user.ID, mailboxName, "", nil)
|
||||
return imapErr(err)
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Delete(mailboxName string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName)
|
||||
if mbox == nil {
|
||||
return noSuchMailbox()
|
||||
}
|
||||
if mbox.Type != "" {
|
||||
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Cannot delete system mailbox"}
|
||||
}
|
||||
_, err := s.deps.DB.SQL().ExecContext(ctx, "DELETE FROM mailboxes WHERE id=?", mbox.ID)
|
||||
return imapErr(err)
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Rename(oldName, newName string, options *imap.RenameOptions) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, oldName)
|
||||
if mbox == nil {
|
||||
return noSuchMailbox()
|
||||
}
|
||||
return imapErr(s.deps.DB.RenameMailbox(ctx, mbox.ID, newName))
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Subscribe(mailboxName string) error {
|
||||
return s.setSubscribed(mailboxName, true)
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Unsubscribe(mailboxName string) error {
|
||||
return s.setSubscribed(mailboxName, false)
|
||||
}
|
||||
|
||||
func (s *IMAPSession) setSubscribed(name string, sub bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, name)
|
||||
if mbox == nil {
|
||||
return noSuchMailbox()
|
||||
}
|
||||
return imapErr(s.deps.DB.SetMailboxSubscribed(ctx, mbox.ID, sub))
|
||||
}
|
||||
|
||||
func (s *IMAPSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if len(patterns) == 0 {
|
||||
return w.WriteList(&imap.ListData{
|
||||
Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect},
|
||||
Delim: mailboxDelim,
|
||||
})
|
||||
}
|
||||
|
||||
mailboxes, err := s.deps.DB.ListMailboxes(ctx, s.user.ID)
|
||||
if err != nil {
|
||||
return imapErr(err)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
data imap.ListData
|
||||
}
|
||||
var entries []entry
|
||||
|
||||
for _, mbox := range mailboxes {
|
||||
if options.SelectSubscribed && !mbox.Subscribed {
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
for _, pat := range patterns {
|
||||
if imapserver.MatchList(mbox.Name, mailboxDelim, ref, pat) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
|
||||
data := imap.ListData{
|
||||
Mailbox: mbox.Name,
|
||||
Delim: mailboxDelim,
|
||||
Attrs: mboxAttrs(mbox),
|
||||
}
|
||||
if mbox.Subscribed {
|
||||
data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed)
|
||||
}
|
||||
if options.ReturnStatus != nil {
|
||||
sd, _ := s.statusFor(ctx, mbox, options.ReturnStatus)
|
||||
data.Status = sd
|
||||
}
|
||||
entries = append(entries, entry{name: mbox.Name, data: data})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
|
||||
for _, e := range entries {
|
||||
if err := w.WriteList(&e.data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Status(mailboxName string, options *imap.StatusOptions) (*imap.StatusData, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName)
|
||||
if mbox == nil {
|
||||
return nil, noSuchMailbox()
|
||||
}
|
||||
return s.statusFor(ctx, mbox, options)
|
||||
}
|
||||
|
||||
func (s *IMAPSession) statusFor(ctx context.Context, mbox *models.Mailbox, opts *imap.StatusOptions) (*imap.StatusData, error) {
|
||||
data := &imap.StatusData{Mailbox: mbox.Name}
|
||||
|
||||
total, unseen, err := s.deps.DB.GetMailboxMessageCounts(ctx, mbox.ID)
|
||||
if err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
|
||||
if opts.NumMessages {
|
||||
n := uint32(total)
|
||||
data.NumMessages = &n
|
||||
}
|
||||
if opts.NumUnseen {
|
||||
n := uint32(unseen)
|
||||
data.NumUnseen = &n
|
||||
}
|
||||
if opts.UIDNext {
|
||||
data.UIDNext = imap.UID(mbox.UIDNext)
|
||||
}
|
||||
if opts.UIDValidity {
|
||||
data.UIDValidity = mbox.UIDValidity
|
||||
}
|
||||
if opts.NumRecent {
|
||||
n := uint32(0)
|
||||
data.NumRecent = &n
|
||||
}
|
||||
if opts.NumDeleted {
|
||||
n := uint32(0)
|
||||
data.NumDeleted = &n
|
||||
}
|
||||
if opts.Size {
|
||||
sz, _ := s.deps.DB.GetMailboxSize(ctx, mbox.ID)
|
||||
data.Size = &sz
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Append(mailboxName string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName)
|
||||
if mbox == nil {
|
||||
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such mailbox"}
|
||||
}
|
||||
|
||||
raw, err := readLiteral(r, s.deps.Cfg.MaxMessageSize)
|
||||
if err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
|
||||
key, err := s.deps.Crypt.DeriveKey("messages", s.user.ID)
|
||||
if err != nil {
|
||||
return nil, imapErr(fmt.Errorf("derive key: %w", err))
|
||||
}
|
||||
rawEnc, err := crypto.Encrypt(key, raw)
|
||||
if err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
|
||||
uid, err := s.deps.DB.NextUID(ctx, mbox.ID)
|
||||
if err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
|
||||
internalDate := time.Now().UTC()
|
||||
isRead, isDraft := false, false
|
||||
var extraParts []string
|
||||
|
||||
if options != nil {
|
||||
if !options.Time.IsZero() {
|
||||
internalDate = options.Time
|
||||
}
|
||||
for _, f := range options.Flags {
|
||||
switch f {
|
||||
case imap.FlagSeen:
|
||||
isRead = true
|
||||
case imap.FlagDraft:
|
||||
isDraft = true
|
||||
default:
|
||||
extraParts = append(extraParts, string(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ins := &db.MessageInsert{
|
||||
MailboxID: mbox.ID,
|
||||
UID: uid,
|
||||
SizeBytes: int64(len(raw)),
|
||||
RawEnc: rawEnc,
|
||||
IsRead: isRead,
|
||||
IsDraft: isDraft,
|
||||
Flags: strings.Join(extraParts, " "),
|
||||
Date: internalDate,
|
||||
}
|
||||
if _, err := s.deps.DB.InsertMessage(ctx, ins); err != nil {
|
||||
return nil, imapErr(err)
|
||||
}
|
||||
|
||||
return &imap.AppendData{
|
||||
UIDValidity: mbox.UIDValidity,
|
||||
UID: imap.UID(uid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
|
||||
if s.sessionTracker == nil {
|
||||
return nil
|
||||
}
|
||||
return s.sessionTracker.Poll(w, allowExpunge)
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
|
||||
if s.sessionTracker == nil {
|
||||
return nil
|
||||
}
|
||||
return s.sessionTracker.Idle(w, stop)
|
||||
}
|
||||
|
||||
// ---- Selected-state operations ----
|
||||
|
||||
func (s *IMAPSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error {
|
||||
if s.selectedMailbox == nil {
|
||||
return noSelectedMailbox()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Soft-delete entries marked \Deleted (filtered by UIDs if provided).
|
||||
for i := range s.msgs {
|
||||
m := &s.msgs[i]
|
||||
if uids != nil && !uids.Contains(m.uid) {
|
||||
continue
|
||||
}
|
||||
if m.isDeleted {
|
||||
s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
deletedUIDs, err := s.deps.DB.HardDeleteMessages(ctx, s.selectedMailbox.ID)
|
||||
if err != nil {
|
||||
return imapErr(err)
|
||||
}
|
||||
deletedSet := make(map[imap.UID]struct{}, len(deletedUIDs))
|
||||
for _, uid := range deletedUIDs {
|
||||
deletedSet[imap.UID(uid)] = struct{}{}
|
||||
}
|
||||
|
||||
// Collect seq nums to expunge (in ascending order, write in reverse).
|
||||
var seqNums []uint32
|
||||
for i, m := range s.msgs {
|
||||
if _, ok := deletedSet[m.uid]; ok {
|
||||
seqNums = append(seqNums, uint32(i+1))
|
||||
}
|
||||
}
|
||||
for i := len(seqNums) - 1; i >= 0; i-- {
|
||||
if err := w.WriteExpunge(seqNums[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from in-memory list.
|
||||
filtered := s.msgs[:0]
|
||||
for _, m := range s.msgs {
|
||||
if _, ok := deletedSet[m.uid]; !ok {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
s.msgs = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
|
||||
if s.selectedMailbox == nil {
|
||||
return nil, noSelectedMailbox()
|
||||
}
|
||||
|
||||
if kind == imapserver.NumKindUID {
|
||||
var result imap.UIDSet
|
||||
for i, m := range s.msgs {
|
||||
seqNum := uint32(i + 1)
|
||||
if s.matchCriteria(seqNum, &m, criteria) {
|
||||
result.AddNum(m.uid)
|
||||
}
|
||||
}
|
||||
return &imap.SearchData{All: result}, nil
|
||||
}
|
||||
|
||||
var result imap.SeqSet
|
||||
for i, m := range s.msgs {
|
||||
seqNum := uint32(i + 1)
|
||||
if s.matchCriteria(seqNum, &m, criteria) {
|
||||
result.AddNum(seqNum)
|
||||
}
|
||||
}
|
||||
return &imap.SearchData{All: result}, nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) matchCriteria(seqNum uint32, m *msgEntry, c *imap.SearchCriteria) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
for _, seqSet := range c.SeqNum {
|
||||
if !seqSet.Contains(seqNum) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, uidSet := range c.UID {
|
||||
if !uidSet.Contains(m.uid) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
flagSet := make(map[imap.Flag]struct{})
|
||||
for _, f := range m.flagList() {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
for _, f := range c.Flag {
|
||||
if _, ok := flagSet[f]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, f := range c.NotFlag {
|
||||
if _, ok := flagSet[f]; ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if c.Larger != 0 && m.size <= c.Larger {
|
||||
return false
|
||||
}
|
||||
if c.Smaller != 0 && m.size >= c.Smaller {
|
||||
return false
|
||||
}
|
||||
if !matchDate(m.internalDate, c.Since, c.Before) {
|
||||
return false
|
||||
}
|
||||
for _, sub := range c.Not {
|
||||
if s.matchCriteria(seqNum, m, &sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, or := range c.Or {
|
||||
if !s.matchCriteria(seqNum, m, &or[0]) && !s.matchCriteria(seqNum, m, &or[1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
|
||||
if s.selectedMailbox == nil {
|
||||
return noSelectedMailbox()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key, err := s.deps.Crypt.DeriveKey("messages", s.user.ID)
|
||||
if err != nil {
|
||||
return imapErr(err)
|
||||
}
|
||||
|
||||
for i := range s.msgs {
|
||||
m := &s.msgs[i]
|
||||
seqNum := uint32(i + 1)
|
||||
if !numSetContains(numSet, seqNum, m.uid) {
|
||||
continue
|
||||
}
|
||||
|
||||
rw := w.CreateMessage(seqNum)
|
||||
rw.WriteUID(m.uid)
|
||||
|
||||
if options.Flags {
|
||||
rw.WriteFlags(m.flagList())
|
||||
}
|
||||
if options.InternalDate {
|
||||
rw.WriteInternalDate(m.internalDate)
|
||||
}
|
||||
if options.RFC822Size {
|
||||
rw.WriteRFC822Size(m.size)
|
||||
}
|
||||
|
||||
needRaw := options.Envelope || options.BodyStructure != nil ||
|
||||
len(options.BodySection) > 0 || len(options.BinarySection) > 0 ||
|
||||
len(options.BinarySectionSize) > 0
|
||||
|
||||
if needRaw {
|
||||
rawEnc, err := s.deps.DB.GetMessageRaw(ctx, m.dbID)
|
||||
if err != nil || rawEnc == nil {
|
||||
rw.Close() //nolint:errcheck
|
||||
continue
|
||||
}
|
||||
raw, err := crypto.Decrypt(key, rawEnc)
|
||||
if err != nil {
|
||||
log.Printf("[imap] decrypt %d: %v", m.dbID, err)
|
||||
rw.Close() //nolint:errcheck
|
||||
continue
|
||||
}
|
||||
|
||||
if options.Envelope {
|
||||
if env := extractEnvelope(raw); env != nil {
|
||||
rw.WriteEnvelope(env)
|
||||
}
|
||||
}
|
||||
if options.BodyStructure != nil {
|
||||
rw.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(raw)))
|
||||
}
|
||||
for _, bs := range options.BodySection {
|
||||
buf := imapserver.ExtractBodySection(bytes.NewReader(raw), bs)
|
||||
wc := rw.WriteBodySection(bs, int64(len(buf)))
|
||||
wc.Write(buf) //nolint:errcheck
|
||||
wc.Close() //nolint:errcheck
|
||||
}
|
||||
for _, bs := range options.BinarySection {
|
||||
buf := imapserver.ExtractBinarySection(bytes.NewReader(raw), bs)
|
||||
wc := rw.WriteBinarySection(bs, int64(len(buf)))
|
||||
wc.Write(buf) //nolint:errcheck
|
||||
wc.Close() //nolint:errcheck
|
||||
}
|
||||
for _, bss := range options.BinarySectionSize {
|
||||
n := imapserver.ExtractBinarySectionSize(bytes.NewReader(raw), bss)
|
||||
rw.WriteBinarySectionSize(bss, n)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
|
||||
if s.selectedMailbox == nil {
|
||||
return noSelectedMailbox()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for i := range s.msgs {
|
||||
m := &s.msgs[i]
|
||||
seqNum := uint32(i + 1)
|
||||
if !numSetContains(numSet, seqNum, m.uid) {
|
||||
continue
|
||||
}
|
||||
applyStoreFlags(m, flags)
|
||||
s.deps.DB.SetMessageFlags(ctx, m.dbID, m.isRead, m.isStarred, m.isDraft, m.extraFlags) //nolint:errcheck
|
||||
if m.isDeleted {
|
||||
s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck
|
||||
}
|
||||
|
||||
if !flags.Silent {
|
||||
rw := w.CreateMessage(seqNum)
|
||||
rw.WriteUID(m.uid)
|
||||
rw.WriteFlags(m.flagList())
|
||||
if err := rw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Copy(numSet imap.NumSet, destName string) (*imap.CopyData, error) {
|
||||
if s.selectedMailbox == nil {
|
||||
return nil, noSelectedMailbox()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dest, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, destName)
|
||||
if dest == nil {
|
||||
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such mailbox"}
|
||||
}
|
||||
|
||||
var sourceUIDs, destUIDs imap.UIDSet
|
||||
for i, m := range s.msgs {
|
||||
seqNum := uint32(i + 1)
|
||||
if !numSetContains(numSet, seqNum, m.uid) {
|
||||
continue
|
||||
}
|
||||
newUID, err := s.deps.DB.CopyMessageToMailbox(ctx, m.dbID, dest.ID, s.user.ID)
|
||||
if err != nil {
|
||||
log.Printf("[imap] copy msg %d: %v", m.dbID, err)
|
||||
continue
|
||||
}
|
||||
sourceUIDs.AddNum(m.uid)
|
||||
destUIDs.AddNum(imap.UID(newUID))
|
||||
}
|
||||
return &imap.CopyData{
|
||||
UIDValidity: dest.UIDValidity,
|
||||
SourceUIDs: sourceUIDs,
|
||||
DestUIDs: destUIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, destName string) error {
|
||||
copyData, err := s.Copy(numSet, destName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteCopyData(copyData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var seqNums []uint32
|
||||
for i := range s.msgs {
|
||||
m := &s.msgs[i]
|
||||
seqNum := uint32(i + 1)
|
||||
if !numSetContains(numSet, seqNum, m.uid) {
|
||||
continue
|
||||
}
|
||||
s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck
|
||||
seqNums = append(seqNums, seqNum)
|
||||
}
|
||||
s.deps.DB.HardDeleteMessages(ctx, s.selectedMailbox.ID) //nolint:errcheck
|
||||
|
||||
for i := len(seqNums) - 1; i >= 0; i-- {
|
||||
if err := w.WriteExpunge(seqNums[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove moved messages.
|
||||
kept := s.msgs[:0]
|
||||
for i, m := range s.msgs {
|
||||
seqNum := uint32(i + 1)
|
||||
if !numSetContains(numSet, seqNum, m.uid) {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
}
|
||||
s.msgs = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IMAPSession) Namespace() (*imap.NamespaceData, error) {
|
||||
return &imap.NamespaceData{
|
||||
Personal: []imap.NamespaceDescriptor{{Delim: mailboxDelim}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
// numSetContains checks seqNum (for SeqSet) or uid (for UIDSet).
|
||||
func numSetContains(numSet imap.NumSet, seqNum uint32, uid imap.UID) bool {
|
||||
switch ns := numSet.(type) {
|
||||
case imap.SeqSet:
|
||||
return ns.Contains(seqNum)
|
||||
case imap.UIDSet:
|
||||
return ns.Contains(uid)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyStoreFlags(m *msgEntry, store *imap.StoreFlags) {
|
||||
flagMap := map[imap.Flag]*bool{
|
||||
imap.FlagSeen: &m.isRead,
|
||||
imap.FlagFlagged: &m.isStarred,
|
||||
imap.FlagDraft: &m.isDraft,
|
||||
imap.FlagDeleted: &m.isDeleted,
|
||||
}
|
||||
switch store.Op {
|
||||
case imap.StoreFlagsSet:
|
||||
m.isRead, m.isStarred, m.isDraft, m.isDeleted = false, false, false, false
|
||||
m.extraFlags = ""
|
||||
for _, f := range store.Flags {
|
||||
if ptr, ok := flagMap[f]; ok {
|
||||
*ptr = true
|
||||
} else {
|
||||
m.extraFlags = strings.TrimSpace(m.extraFlags + " " + string(f))
|
||||
}
|
||||
}
|
||||
case imap.StoreFlagsAdd:
|
||||
for _, f := range store.Flags {
|
||||
if ptr, ok := flagMap[f]; ok {
|
||||
*ptr = true
|
||||
} else if !strings.Contains(m.extraFlags, string(f)) {
|
||||
m.extraFlags = strings.TrimSpace(m.extraFlags + " " + string(f))
|
||||
}
|
||||
}
|
||||
case imap.StoreFlagsDel:
|
||||
for _, f := range store.Flags {
|
||||
if ptr, ok := flagMap[f]; ok {
|
||||
*ptr = false
|
||||
} else {
|
||||
m.extraFlags = strings.TrimSpace(strings.ReplaceAll(m.extraFlags, string(f), ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func allFlags() []imap.Flag {
|
||||
return []imap.Flag{
|
||||
imap.FlagSeen,
|
||||
imap.FlagAnswered,
|
||||
imap.FlagFlagged,
|
||||
imap.FlagDeleted,
|
||||
imap.FlagDraft,
|
||||
}
|
||||
}
|
||||
|
||||
func mboxAttrs(mbox *models.Mailbox) []imap.MailboxAttr {
|
||||
su := db.MailboxTypeToSpecialUse(mbox.Type)
|
||||
if su != "" {
|
||||
return []imap.MailboxAttr{imap.MailboxAttr(su)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractEnvelope(raw []byte) *imap.Envelope {
|
||||
br := bufio.NewReader(bytes.NewReader(raw))
|
||||
header, err := textproto.ReadHeader(br)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return imapserver.ExtractEnvelope(header)
|
||||
}
|
||||
|
||||
func matchDate(t, since, before time.Time) bool {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
if !since.IsZero() && t.Before(since) {
|
||||
return false
|
||||
}
|
||||
if !before.IsZero() && !t.Before(before) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func readLiteral(r imap.LiteralReader, maxSize int64) ([]byte, error) {
|
||||
buf := make([]byte, 0, 4096)
|
||||
tmp := make([]byte, 32768)
|
||||
var total int64
|
||||
for {
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
total += int64(n)
|
||||
if total > maxSize {
|
||||
return nil, fmt.Errorf("message too large")
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func imapErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := err.(*imap.Error); ok {
|
||||
return err
|
||||
}
|
||||
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "server error"}
|
||||
}
|
||||
|
||||
func noSuchMailbox() error {
|
||||
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"}
|
||||
}
|
||||
|
||||
func noSelectedMailbox() error {
|
||||
return &imap.Error{Type: imap.StatusResponseTypeBad, Text: "no mailbox selected"}
|
||||
}
|
||||
Reference in New Issue
Block a user