References: https://todo.sr.ht/~sircmpwn/koushin/5master
@@ -6,6 +6,7 @@ require ( | |||
github.com/aymerick/douceur v0.2.0 | |||
github.com/chris-ramon/douceur v0.2.0 | |||
github.com/emersion/go-imap v1.0.3 | |||
github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915 | |||
github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342 | |||
github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 | |||
github.com/emersion/go-message v0.11.1 | |||
@@ -12,6 +12,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC | |||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= | |||
github.com/emersion/go-imap v1.0.3 h1:5eEee8/DTSIPfliiWqwfvjPGkU8bBtvOy/Wx+eeXzO4= | |||
github.com/emersion/go-imap v1.0.3/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= | |||
github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915 h1:8xzODjLqrfAJo+CNhX0Fp47vdVN0ZvmGV3CPt/Ex1nU= | |||
github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915/go.mod h1:6mXMzbK9Ts0mrrBibqy56SqZpuFMry5AedTgu6qY5zM= | |||
github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342 h1:5p1t3e1PomYgLWwEwhwEU5kVBwcyAcVrOpexv8AeZx0= | |||
github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w= | |||
github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 h1:4ZAfwfc8aDlj26kkEap1UDSwwDnJp9Ie8Uj1MSXAkPk= | |||
@@ -59,7 +59,7 @@ func newServer(e *echo.Echo, options *Options) (*Server, error) { | |||
return nil, err | |||
} | |||
s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP) | |||
s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP, e.Logger) | |||
return s, nil | |||
} | |||
@@ -12,6 +12,7 @@ import ( | |||
imapclient "github.com/emersion/go-imap/client" | |||
"github.com/emersion/go-sasl" | |||
"github.com/emersion/go-smtp" | |||
"github.com/labstack/echo/v4" | |||
) | |||
// TODO: make this configurable | |||
@@ -48,6 +49,7 @@ type Session struct { | |||
closed chan struct{} | |||
pings chan struct{} | |||
timer *time.Timer | |||
store Store | |||
imapLocker sync.Mutex | |||
imapConn *imapclient.Client // protected by locker, can be nil | |||
@@ -122,6 +124,11 @@ func (s *Session) Close() { | |||
} | |||
} | |||
// Store returns a store suitable for storing persistent user data. | |||
func (s *Session) Store() Store { | |||
return s.store | |||
} | |||
type ( | |||
// DialIMAPFunc connects to the upstream IMAP server. | |||
DialIMAPFunc func() (*imapclient.Client, error) | |||
@@ -134,16 +141,18 @@ type ( | |||
type SessionManager struct { | |||
dialIMAP DialIMAPFunc | |||
dialSMTP DialSMTPFunc | |||
logger echo.Logger | |||
locker sync.Mutex | |||
sessions map[string]*Session // protected by locker | |||
} | |||
func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc) *SessionManager { | |||
func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc, logger echo.Logger) *SessionManager { | |||
return &SessionManager{ | |||
sessions: make(map[string]*Session), | |||
dialIMAP: dialIMAP, | |||
dialSMTP: dialSMTP, | |||
logger: logger, | |||
} | |||
} | |||
@@ -185,7 +194,6 @@ func (sm *SessionManager) Put(username, password string) (*Session, error) { | |||
var token string | |||
for { | |||
var err error | |||
token, err = generateToken() | |||
if err != nil { | |||
c.Logout() | |||
@@ -206,6 +214,12 @@ func (sm *SessionManager) Put(username, password string) (*Session, error) { | |||
password: password, | |||
token: token, | |||
} | |||
s.store, err = newStore(s, sm.logger) | |||
if err != nil { | |||
return nil, err | |||
} | |||
sm.sessions[token] = s | |||
go func() { | |||
@@ -0,0 +1,141 @@ | |||
package koushin | |||
import ( | |||
"encoding/json" | |||
"fmt" | |||
"reflect" | |||
"sync" | |||
imapmetadata "github.com/emersion/go-imap-metadata" | |||
imapclient "github.com/emersion/go-imap/client" | |||
"github.com/labstack/echo/v4" | |||
) | |||
// ErrNoStoreEntry is returned by Store.Get when the entry doesn't exist. | |||
var ErrNoStoreEntry = fmt.Errorf("koushin: no such entry in store") | |||
// Store allows storing per-user persistent data. | |||
// | |||
// Store shouldn't be used from inside Session.DoIMAP. | |||
type Store interface { | |||
Get(key string, out interface{}) error | |||
Put(key string, v interface{}) error | |||
} | |||
var warnedTransientStore = false | |||
func newStore(session *Session, logger echo.Logger) (Store, error) { | |||
s, err := newIMAPStore(session) | |||
if err == nil { | |||
return s, nil | |||
} else if err != errIMAPMetadataUnsupported { | |||
return nil, err | |||
} | |||
if !warnedTransientStore { | |||
logger.Print("Upstream IMAP server doesn't support the METADATA extension, using transient store instead") | |||
warnedTransientStore = true | |||
} | |||
return newMemoryStore(), nil | |||
} | |||
type memoryStore struct { | |||
locker sync.RWMutex | |||
entries map[string]interface{} | |||
} | |||
func newMemoryStore() *memoryStore { | |||
return &memoryStore{entries: make(map[string]interface{})} | |||
} | |||
func (s *memoryStore) Get(key string, out interface{}) error { | |||
s.locker.RLock() | |||
defer s.locker.RUnlock() | |||
v, ok := s.entries[key] | |||
if !ok { | |||
return ErrNoStoreEntry | |||
} | |||
reflect.ValueOf(out).Elem().Set(reflect.ValueOf(v).Elem()) | |||
return nil | |||
} | |||
func (s *memoryStore) Put(key string, v interface{}) error { | |||
s.locker.Lock() | |||
s.entries[key] = v | |||
s.locker.Unlock() | |||
return nil | |||
} | |||
type imapStore struct { | |||
session *Session | |||
cache *memoryStore | |||
} | |||
var errIMAPMetadataUnsupported = fmt.Errorf("koushin: IMAP server doesn't support METADATA extension") | |||
func newIMAPStore(session *Session) (*imapStore, error) { | |||
err := session.DoIMAP(func(c *imapclient.Client) error { | |||
mc := imapmetadata.NewClient(c) | |||
ok, err := mc.SupportMetadata() | |||
if err != nil { | |||
return fmt.Errorf("koushin: failed to check for IMAP METADATA support: %v", err) | |||
} | |||
if !ok { | |||
return errIMAPMetadataUnsupported | |||
} | |||
return nil | |||
}) | |||
if err != nil { | |||
return nil, err | |||
} | |||
return &imapStore{session, newMemoryStore()}, nil | |||
} | |||
func (s *imapStore) key(key string) string { | |||
return "/private/vendor/koushin/" + key | |||
} | |||
func (s *imapStore) Get(key string, out interface{}) error { | |||
if err := s.cache.Get(key, out); err != ErrNoStoreEntry { | |||
return err | |||
} | |||
var entries map[string]string | |||
err := s.session.DoIMAP(func(c *imapclient.Client) error { | |||
mc := imapmetadata.NewClient(c) | |||
var err error | |||
entries, err = mc.GetMetadata("", []string{s.key(key)}, nil) | |||
return err | |||
}) | |||
if err != nil { | |||
return fmt.Errorf("koushin: failed to fetch IMAP store entry %q: %v", key, err) | |||
} | |||
v, ok := entries[s.key(key)] | |||
if !ok { | |||
return ErrNoStoreEntry | |||
} | |||
if err := json.Unmarshal([]byte(v), out); err != nil { | |||
return fmt.Errorf("koushin: failed to unmarshal IMAP store entry %q: %v", key, err) | |||
} | |||
return s.cache.Put(key, out) | |||
} | |||
func (s *imapStore) Put(key string, v interface{}) error { | |||
b, err := json.Marshal(v) | |||
if err != nil { | |||
return fmt.Errorf("koushin: failed to marshal IMAP store entry %q: %v", key, err) | |||
} | |||
entries := map[string]string{ | |||
s.key(key): string(b), | |||
} | |||
err = s.session.DoIMAP(func(c *imapclient.Client) error { | |||
mc := imapmetadata.NewClient(c) | |||
return mc.SetMetadata("", entries) | |||
}) | |||
if err != nil { | |||
return fmt.Errorf("koushin: failed to put IMAP store entry %q: %v", key, err) | |||
} | |||
return s.cache.Put(key, v) | |||
} |