├── .gitignore ├── constants └── constants.go ├── rfc822 ├── package.go ├── hash_test.go ├── writer.go ├── writer_test.go ├── testdata │ └── hash_utf8.eml └── mime.go ├── tests ├── testdata │ ├── dovecot-crlf │ ├── afternoon-meeting.eml │ ├── space_line_header.eml │ └── embedded-rfc822.eml ├── main_test.go ├── init_test.go ├── noop_test.go ├── logout_test.go ├── invalid_test.go ├── starttls_test.go ├── unselect_test.go ├── check_test.go ├── multi_user_test.go ├── db_test.go ├── migration_test.go ├── subscribe_test.go ├── parsed_message_test.go ├── non_utf8_test.go ├── status_test.go ├── capability_test.go └── counts_test.go ├── benchmarks ├── imaptest │ ├── dovecot-crlf │ └── benchmark.yml └── gluon_bench │ ├── main.go │ ├── reporter │ ├── stdout_reporter.go │ └── json_reporter.go │ ├── store_benchmarks │ ├── store.go │ ├── store_factory.go │ └── create.go │ ├── flags │ ├── store_benchmarks.go │ ├── general.go │ └── imap_benchmarks.go │ ├── imap_benchmarks │ ├── server │ │ ├── server.go │ │ └── remote.go │ └── state_tracker.go │ ├── timing │ └── timing.go │ ├── benchmark │ ├── benchmark.go │ └── bench_dir_config.go │ ├── README.md │ └── utils │ └── utils.go ├── events ├── login.go ├── select.go ├── login_failed.go ├── events.go ├── id.go ├── listener.go ├── session.go └── user.go ├── store ├── fallback_v0 │ ├── compressor.go │ ├── compressor_gzip.go │ └── compressor_zlib.go ├── hash.go ├── package.go ├── store.go ├── fallback.go └── option.go ├── internal ├── response │ ├── item.go │ ├── expunge_test.go │ ├── item_read_only.go │ ├── exists_test.go │ ├── item_trycreate.go │ ├── recent_test.go │ ├── item_read_write.go │ ├── item_badcharset.go │ ├── item_expungeissued.go │ ├── format.go │ ├── continuation_test.go │ ├── item_recent.go │ ├── item_unseen.go │ ├── error.go │ ├── item_messages.go │ ├── item_body.go │ ├── bye_test.go │ ├── item_rfc822_size.go │ ├── item_envelope.go │ ├── item_rfc822_text.go │ ├── item_uid_next.go │ ├── types.go │ ├── item_bodystructure.go │ ├── search_test.go │ ├── item_rfc822_header.go │ ├── item_uid_validity.go │ ├── flags_test.go │ ├── item_rfc822_literal.go │ ├── fetch_test.go │ ├── status_test.go │ ├── item_body_literal_test.go │ ├── item_permanent_flags.go │ ├── id.go │ ├── expunge.go │ ├── bad_test.go │ ├── item_internal_date.go │ ├── capability_test.go │ ├── item_appenduid.go │ ├── item_uid.go │ ├── continuation.go │ ├── flags.go │ ├── item_flags.go │ ├── list_test.go │ ├── lsub_test.go │ ├── item_capability.go │ ├── item_copyuid.go │ ├── no_test.go │ ├── search.go │ ├── bad.go │ ├── bye.go │ ├── status.go │ ├── capability.go │ ├── list.go │ ├── lsub.go │ ├── item_body_literal.go │ ├── ok.go │ ├── recent.go │ ├── ok_test.go │ ├── exists.go │ └── no.go ├── backend │ ├── errors.go │ ├── connector_state_read.go │ └── connector_state.go ├── db_impl │ ├── sqlite3 │ │ ├── v2 │ │ │ ├── constants.go │ │ │ └── migration.go │ │ ├── client_test.go │ │ └── types.go │ └── db_impl.go ├── ids │ ├── header.go │ └── ids.go ├── hash │ └── hash.go ├── data │ └── db.go ├── unleash │ └── unleash.go ├── session │ ├── init.go │ ├── responses.go │ ├── logger.go │ ├── flags.go │ ├── handle_check.go │ ├── handle_unselect.go │ ├── handle_sub.go │ ├── handle_unsub.go │ ├── handle_logout.go │ ├── handle_starttls.go │ ├── context.go │ ├── handle_noop.go │ ├── handle_rename.go │ ├── handle_capability.go │ ├── handle_close.go │ ├── handle_uid.go │ ├── handle_create.go │ ├── handle_delete.go │ ├── handle_lsub.go │ ├── handle_list.go │ ├── errors.go │ └── handle_id.go ├── state │ ├── context.go │ ├── user_interface.go │ ├── paths.go │ └── errors.go ├── utils │ └── utils.go └── ticker │ └── ticker.go ├── connector └── package.go ├── imap ├── update.go ├── status.go ├── command │ ├── done.go │ ├── idle.go │ ├── noop.go │ ├── check.go │ ├── close.go │ ├── logout.go │ ├── expunge.go │ ├── starttls.go │ ├── unselect.go │ ├── capability.go │ ├── mailbox.go │ ├── idle_test.go │ ├── noop_test.go │ ├── check_test.go │ ├── close_test.go │ ├── logout_test.go │ ├── nstring.go │ ├── expunge_test.go │ ├── starttls_test.go │ ├── unselect_test.go │ ├── capability_test.go │ ├── create_test.go │ ├── delete_test.go │ ├── select_test.go │ ├── examine_test.go │ ├── rename_test.go │ ├── subscribte_test.go │ ├── unsubscribe_test.go │ ├── login_test.go │ ├── copy_test.go │ ├── move_test.go │ ├── command.go │ ├── create.go │ ├── delete.go │ ├── select.go │ ├── examine.go │ ├── done_test.go │ ├── subscribe.go │ ├── seq_set_test.go │ ├── unsubscribe.go │ ├── nstring_test.go │ ├── input_collector_test.go │ ├── lsub.go │ ├── copy.go │ ├── flags_test.go │ ├── move.go │ ├── rename.go │ ├── login.go │ └── input_collector.go ├── update_noop.go ├── utils.go ├── message.go ├── update_uid_validity_bumped.go ├── attributes.go ├── update_message_deleted.go ├── mailbox.go ├── update_mailbox_created.go ├── capabilities.go ├── update_mailbox_id_changed.go ├── update_message_id_changed.go ├── update_mailbox_created_or_updated.go ├── update_message_flags_updated.go ├── update_mailbox_updated.go ├── seqset_test.go ├── testdata │ ├── rfc822.eml │ └── bounds.eml ├── update_message_labels_updated.go ├── envelope_test.go ├── update_mailbox_deleted.go ├── update_waiter.go ├── uid_validity_generator_test.go ├── seqset.go └── strong_types.go ├── logging ├── pprof_disabled.go ├── pprof_default.go ├── flags_default.go ├── flags_debug.go └── logging.go ├── .releaserc ├── async ├── logging.go ├── wait_group.go ├── panic_handler.go ├── bool.go └── panic_handler_test.go ├── .github └── workflows │ └── release.yml ├── version └── info.go ├── reporter ├── reporter.go ├── context.go ├── null_reporter.go └── utils.go ├── db ├── ops_subscription.go ├── ops.go └── client.go ├── errors.go ├── rfc5322 ├── quoted_test.go ├── atom_test.go ├── miscelleaneous_test.go └── cfws_test.go ├── observability ├── context.go ├── observability.go └── utils.go ├── profiling └── context.go ├── .golangci.yml ├── server_test.go ├── watcher ├── watcher.go └── watcher_test.go ├── LICENSE ├── go.mod └── rfcvalidation └── validation_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | -------------------------------------------------------------------------------- /constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ChannelBufferCount = 100 4 | -------------------------------------------------------------------------------- /rfc822/package.go: -------------------------------------------------------------------------------- 1 | // Package rfc822 implements methods for handling RFC822 messages. 2 | package rfc822 3 | -------------------------------------------------------------------------------- /tests/testdata/dovecot-crlf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/gluon/dev/tests/testdata/dovecot-crlf -------------------------------------------------------------------------------- /benchmarks/imaptest/dovecot-crlf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/gluon/dev/benchmarks/imaptest/dovecot-crlf -------------------------------------------------------------------------------- /events/login.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type Login struct { 4 | eventBase 5 | 6 | SessionID int 7 | UserID string 8 | } 9 | -------------------------------------------------------------------------------- /events/select.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type Select struct { 4 | eventBase 5 | 6 | SessionID int 7 | Mailbox string 8 | } 9 | -------------------------------------------------------------------------------- /events/login_failed.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type LoginFailed struct { 4 | eventBase 5 | 6 | SessionID int 7 | Username string 8 | } 9 | -------------------------------------------------------------------------------- /events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type Event interface { 4 | _isEvent() 5 | } 6 | 7 | type eventBase struct{} 8 | 9 | func (eventBase) _isEvent() {} 10 | -------------------------------------------------------------------------------- /store/fallback_v0/compressor.go: -------------------------------------------------------------------------------- 1 | package fallback_v0 2 | 3 | type Compressor interface { 4 | Compress([]byte) ([]byte, error) 5 | Decompress([]byte) ([]byte, error) 6 | } 7 | -------------------------------------------------------------------------------- /internal/response/item.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type Item interface { 4 | String() string 5 | } 6 | 7 | type mergeableItem interface { 8 | mergeWith(other Item) Item 9 | } 10 | -------------------------------------------------------------------------------- /events/id.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "github.com/ProtonMail/gluon/imap" 4 | 5 | type IMAPID struct { 6 | eventBase 7 | 8 | SessionID int 9 | IMAPID imap.IMAPID 10 | } 11 | -------------------------------------------------------------------------------- /internal/backend/errors.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNoSuchUser = errors.New("no such user") 7 | ErrLoginBlocked = errors.New("too many login attempts") 8 | ) 9 | -------------------------------------------------------------------------------- /connector/package.go: -------------------------------------------------------------------------------- 1 | // Package connector defines the type that connects the server to a remote. 2 | package connector 3 | 4 | //go:generate mockgen -destination mock_connector/connector.go . Connector 5 | -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "go.uber.org/goleak" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) 11 | } 12 | -------------------------------------------------------------------------------- /imap/update.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | type Update interface { 4 | Waiter 5 | 6 | String() string 7 | 8 | _isUpdate() 9 | } 10 | 11 | type updateBase struct{} 12 | 13 | func (updateBase) _isUpdate() {} 14 | -------------------------------------------------------------------------------- /events/listener.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "net" 4 | 5 | type ListenerAdded struct { 6 | eventBase 7 | 8 | Addr net.Addr 9 | } 10 | 11 | type ListenerRemoved struct { 12 | eventBase 13 | 14 | Addr net.Addr 15 | } 16 | -------------------------------------------------------------------------------- /imap/status.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | const ( 4 | StatusMessages = `MESSAGES` 5 | StatusRecent = `RECENT` 6 | StatusUIDNext = `UIDNEXT` 7 | StatusUIDValidity = `UIDVALIDITY` 8 | StatusUnseen = `UNSEEN` 9 | ) 10 | -------------------------------------------------------------------------------- /internal/db_impl/sqlite3/v2/constants.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | const ConnectorSettingsTableName = "connector_settings" 4 | const ConnectorSettingsFieldID = "id" 5 | const ConnectorSettingsFieldValue = "value" 6 | const ConnectorSettingsDefaultID = 0 7 | -------------------------------------------------------------------------------- /internal/response/expunge_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExpunge(t *testing.T) { 10 | assert.Equal(t, `* 23 EXPUNGE`, Expunge(23).String()) 11 | } 12 | -------------------------------------------------------------------------------- /internal/response/item_read_only.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type itemReadOnly struct{} 4 | 5 | func ItemReadOnly() *itemReadOnly { 6 | return &itemReadOnly{} 7 | } 8 | 9 | func (c *itemReadOnly) String() string { 10 | return "READ-ONLY" 11 | } 12 | -------------------------------------------------------------------------------- /internal/response/exists_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExists(t *testing.T) { 10 | assert.Equal(t, `* 23 EXISTS`, Exists().WithCount(23).String()) 11 | } 12 | -------------------------------------------------------------------------------- /internal/response/item_trycreate.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type itemTryCreate struct{} 4 | 5 | func ItemTryCreate() *itemTryCreate { 6 | return &itemTryCreate{} 7 | } 8 | 9 | func (c *itemTryCreate) String() string { 10 | return "TRYCREATE" 11 | } 12 | -------------------------------------------------------------------------------- /internal/response/recent_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRecent(t *testing.T) { 10 | assert.Equal(t, `* 5 RECENT`, Recent().WithCount(5).String()) 11 | } 12 | -------------------------------------------------------------------------------- /store/hash.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "crypto/sha256" 5 | ) 6 | 7 | func hash(b []byte) []byte { 8 | hash := sha256.New() 9 | 10 | if _, err := hash.Write(b); err != nil { 11 | panic(err) 12 | } 13 | 14 | return hash.Sum(nil) 15 | } 16 | -------------------------------------------------------------------------------- /internal/response/item_read_write.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type itemReadWrite struct{} 4 | 5 | func ItemReadWrite() *itemReadWrite { 6 | return &itemReadWrite{} 7 | } 8 | 9 | func (c *itemReadWrite) String() string { 10 | return "READ-WRITE" 11 | } 12 | -------------------------------------------------------------------------------- /logging/pprof_disabled.go: -------------------------------------------------------------------------------- 1 | //go:build gluon_pprof_disabled 2 | 3 | package logging 4 | 5 | import ( 6 | "context" 7 | "runtime/pprof" 8 | ) 9 | 10 | func pprofDo(ctx context.Context, labels pprof.LabelSet, fn func(context.Context)) { 11 | fn(ctx) 12 | } 13 | -------------------------------------------------------------------------------- /tests/init_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func init() { 10 | if level, err := logrus.ParseLevel(os.Getenv("GLUON_LOG_LEVEL")); err == nil { 11 | logrus.SetLevel(level) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /imap/command/done.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Done struct{} 8 | 9 | func (l Done) String() string { 10 | return fmt.Sprintf("DONE") 11 | } 12 | 13 | func (l Done) SanitizedString() string { 14 | return l.String() 15 | } 16 | -------------------------------------------------------------------------------- /internal/response/item_badcharset.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type itemBadCharset struct{} 4 | 5 | func ItemBadCharset() *itemBadCharset { 6 | return &itemBadCharset{} 7 | } 8 | 9 | func (c *itemBadCharset) String() string { 10 | return "BADCHARSET" 11 | } 12 | -------------------------------------------------------------------------------- /tests/noop_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNoop(t *testing.T) { 8 | runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { 9 | c.C("a001 noop") 10 | c.OK("a001") 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /events/session.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "net" 4 | 5 | type SessionAdded struct { 6 | eventBase 7 | 8 | SessionID int 9 | LocalAddr net.Addr 10 | RemoteAddr net.Addr 11 | } 12 | 13 | type SessionRemoved struct { 14 | eventBase 15 | 16 | SessionID int 17 | } 18 | -------------------------------------------------------------------------------- /logging/pprof_default.go: -------------------------------------------------------------------------------- 1 | //go:build !gluon_pprof_disabled 2 | 3 | package logging 4 | 5 | import ( 6 | "context" 7 | "runtime/pprof" 8 | ) 9 | 10 | func pprofDo(ctx context.Context, labels pprof.LabelSet, fn func(context.Context)) { 11 | pprof.Do(ctx, labels, fn) 12 | } 13 | -------------------------------------------------------------------------------- /imap/update_noop.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | type Noop struct { 4 | updateBase 5 | 6 | *updateWaiter 7 | } 8 | 9 | func NewNoop() *Noop { 10 | return &Noop{ 11 | updateWaiter: newUpdateWaiter(), 12 | } 13 | } 14 | 15 | func (u *Noop) String() string { 16 | return "Noop" 17 | } 18 | -------------------------------------------------------------------------------- /imap/utils.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | // ShortID return a string containing a short version of the given ID. Use only for debug display. 4 | func ShortID(id string) string { 5 | const l = 36 6 | 7 | if len(id) < l { 8 | return id 9 | } 10 | 11 | return id[0:l] + "..." 12 | } 13 | -------------------------------------------------------------------------------- /internal/response/item_expungeissued.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type itemExpungeIssued struct{} 4 | 5 | func ItemExpungeIssued() *itemExpungeIssued { 6 | return &itemExpungeIssued{} 7 | } 8 | 9 | func (c *itemExpungeIssued) String() string { 10 | return "EXPUNGEISSUED" 11 | } 12 | -------------------------------------------------------------------------------- /events/user.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "github.com/ProtonMail/gluon/imap" 4 | 5 | type UserAdded struct { 6 | eventBase 7 | 8 | UserID string 9 | 10 | Counts map[imap.MailboxID]int 11 | } 12 | 13 | type UserRemoved struct { 14 | eventBase 15 | 16 | UserID string 17 | } 18 | -------------------------------------------------------------------------------- /internal/ids/header.go: -------------------------------------------------------------------------------- 1 | package ids 2 | 3 | // InternalIDKey is the key of the header entry we add to messages in the mailserver system. 4 | // This allows us to detect when clients try to create a duplicate of a message, which we treat instead as a copy. 5 | const InternalIDKey = `X-Pm-Gluon-Id` 6 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/changelog", 6 | ["@semantic-release/git", { 7 | "assets": ["internal/parser/lib"] 8 | }], 9 | "@semantic-release/github" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /logging/flags_default.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package logging 4 | 5 | import "runtime" 6 | 7 | func getDefaultLabels(pc uintptr, file string, line int) Labels { 8 | return Labels{ 9 | FnKey: runtime.FuncForPC(pc).Name(), 10 | FileKey: file, 11 | LineKey: line, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/response/format.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "strings" 4 | 5 | func join(items []string, withDel ...string) string { 6 | var del string 7 | 8 | if len(withDel) > 0 { 9 | del = withDel[0] 10 | } else { 11 | del = " " 12 | } 13 | 14 | return strings.Join(items, del) 15 | } 16 | -------------------------------------------------------------------------------- /store/package.go: -------------------------------------------------------------------------------- 1 | // package store implements types that store message literals. 2 | // 3 | // Messages may be stored either in-memory or on-disk. 4 | // When stored on disk, they are stored encrypted and optionally compressed. 5 | package store 6 | 7 | //go:generate mockgen -destination mock_store/store.go . Store 8 | -------------------------------------------------------------------------------- /internal/response/continuation_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestContinuation(t *testing.T) { 10 | assert.Equal(t, "+ Ready", Continuation().String("Ready")) 11 | assert.Equal(t, "+", Continuation().String("")) 12 | } 13 | -------------------------------------------------------------------------------- /tests/logout_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLogout(t *testing.T) { 8 | runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { 9 | c.C("a001 logout") 10 | c.S("* BYE") 11 | c.OK("a001") 12 | c.expectClosed() 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /internal/hash/hash.go: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | ) 7 | 8 | func SHA256(key []byte) []byte { 9 | hash := sha256.Sum256(key) 10 | 11 | return hash[:] 12 | } 13 | 14 | func HashToString(entry string) string { 15 | return hex.EncodeToString(SHA256([]byte(entry))) 16 | } 17 | -------------------------------------------------------------------------------- /internal/response/item_recent.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemRecent struct { 6 | n int 7 | } 8 | 9 | func ItemRecent(n int) *itemRecent { 10 | return &itemRecent{ 11 | n: n, 12 | } 13 | } 14 | 15 | func (s *itemRecent) String() string { 16 | return fmt.Sprintf("RECENT %v", s.n) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/item_unseen.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemUnseen struct { 6 | count uint32 7 | } 8 | 9 | func ItemUnseen(n uint32) *itemUnseen { 10 | return &itemUnseen{count: n} 11 | } 12 | 13 | func (c *itemUnseen) String() string { 14 | return fmt.Sprintf("UNSEEN %v", c.count) 15 | } 16 | -------------------------------------------------------------------------------- /tests/invalid_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestInvalidIMAPCommandBadTag(t *testing.T) { 8 | runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { 9 | c.C("A006 RANDOMGIBBERISHTHATDOESNOTMAKEAVALIDIMAPCOMMAND").BAD("A006") 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /internal/response/error.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "errors" 4 | 5 | func FromError(err error) (Response, bool) { 6 | var no *no 7 | 8 | if errors.As(err, &no) { 9 | return no, true 10 | } 11 | 12 | var bad *bad 13 | 14 | if errors.As(err, &bad) { 15 | return bad, true 16 | } 17 | 18 | return nil, false 19 | } 20 | -------------------------------------------------------------------------------- /internal/response/item_messages.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemMessages struct { 6 | n int 7 | } 8 | 9 | func ItemMessages(n int) *itemMessages { 10 | return &itemMessages{ 11 | n: n, 12 | } 13 | } 14 | 15 | func (s *itemMessages) String() string { 16 | return fmt.Sprintf("MESSAGES %v", s.n) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/item_body.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemBody struct { 6 | structure string 7 | } 8 | 9 | func ItemBody(structure string) *itemBody { 10 | return &itemBody{ 11 | structure: structure, 12 | } 13 | } 14 | 15 | func (r *itemBody) String() string { 16 | return fmt.Sprintf("BODY %v", r.structure) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/bye_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBye(t *testing.T) { 10 | assert.Equal(t, "* BYE", Bye().String()) 11 | } 12 | 13 | func TestByeMessage(t *testing.T) { 14 | assert.Equal(t, "* BYE message", Bye().WithMessage("message").String()) 15 | } 16 | -------------------------------------------------------------------------------- /imap/message.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Message struct { 8 | ID MessageID 9 | Flags FlagSet 10 | Date time.Time 11 | } 12 | 13 | type Header []Field 14 | 15 | type Field struct { 16 | Key, Value string 17 | } 18 | 19 | func (m *Message) HasFlag(wantFlag string) bool { 20 | return m.Flags.Contains(wantFlag) 21 | } 22 | -------------------------------------------------------------------------------- /internal/response/item_rfc822_size.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemRFC822Size struct { 6 | size int 7 | } 8 | 9 | func ItemRFC822Size(size int) *itemRFC822Size { 10 | return &itemRFC822Size{ 11 | size: size, 12 | } 13 | } 14 | 15 | func (s *itemRFC822Size) String() string { 16 | return fmt.Sprintf("RFC822.SIZE %v", s.size) 17 | } 18 | -------------------------------------------------------------------------------- /async/logging.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/logging" 7 | ) 8 | 9 | func GoAnnotated(ctx context.Context, panicHandler PanicHandler, fn func(context.Context), labelMap ...logging.Labels) { 10 | go func() { 11 | defer HandlePanic(panicHandler) 12 | logging.DoAnnotated(ctx, fn, labelMap...) 13 | }() 14 | } 15 | -------------------------------------------------------------------------------- /tests/starttls_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import "testing" 4 | 5 | func TestStartTLS(t *testing.T) { 6 | runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { 7 | c.C("A001 starttls") 8 | c.S("A001 OK Begin TLS negotiation now") 9 | 10 | c.upgradeConnection() 11 | 12 | c.C("A002 noop") 13 | c.OK("A002") 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release workflow 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Get sources 12 | uses: actions/checkout@v3 13 | 14 | - uses: cycjimmy/semantic-release-action@v3 15 | env: 16 | GITHUB_TOKEN: ${{ github.token }} 17 | -------------------------------------------------------------------------------- /internal/response/item_envelope.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemEnvelope struct { 6 | envelope string 7 | } 8 | 9 | func ItemEnvelope(envelope string) *itemEnvelope { 10 | return &itemEnvelope{ 11 | envelope: envelope, 12 | } 13 | } 14 | 15 | func (r *itemEnvelope) String() string { 16 | return fmt.Sprintf("ENVELOPE %v", r.envelope) 17 | } 18 | -------------------------------------------------------------------------------- /version/info.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "fmt" 4 | 5 | type Version struct { 6 | Major, Minor, Patch int 7 | } 8 | 9 | func (v *Version) String() string { 10 | return fmt.Sprintf("%02v.%02v.%02v", v.Major, v.Minor, v.Patch) 11 | } 12 | 13 | type Info struct { 14 | Name string 15 | Version Version 16 | Vendor string 17 | SupportURL string 18 | } 19 | -------------------------------------------------------------------------------- /tests/testdata/afternoon-meeting.eml: -------------------------------------------------------------------------------- 1 | Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST) 2 | From: Fred Foobar 3 | Subject: afternoon meeting 4 | To: mooch@owatagu.siam.edu 5 | Message-Id: 6 | MIME-Version: 1.0 7 | Content-Type: TEXT/PLAIN; CHARSET=US-ASCII 8 | 9 | Hello Joe, do you think we can meet at 3:30 tomorrow? 10 | 11 | -------------------------------------------------------------------------------- /internal/response/item_rfc822_text.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemRFC822Text struct { 6 | text []byte 7 | } 8 | 9 | func ItemRFC822Text(text []byte) *itemRFC822Text { 10 | return &itemRFC822Text{ 11 | text: text, 12 | } 13 | } 14 | 15 | func (r *itemRFC822Text) String() string { 16 | return fmt.Sprintf("RFC822.TEXT {%v}\r\n%s", len(r.text), r.text) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/item_uid_next.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemUIDNext struct { 10 | uid imap.UID 11 | } 12 | 13 | func ItemUIDNext(n imap.UID) *itemUIDNext { 14 | return &itemUIDNext{uid: n} 15 | } 16 | 17 | func (c *itemUIDNext) String() string { 18 | return fmt.Sprintf("UIDNEXT %v", c.uid) 19 | } 20 | -------------------------------------------------------------------------------- /internal/data/db.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | ) 8 | 9 | // pathExists returns whether the given file exists. 10 | func pathExists(path string) (bool, error) { 11 | if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { 12 | return false, nil 13 | } else if err != nil { 14 | return false, err 15 | } 16 | 17 | return true, nil 18 | } 19 | -------------------------------------------------------------------------------- /imap/update_uid_validity_bumped.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import "fmt" 4 | 5 | type UIDValidityBumped struct { 6 | updateBase 7 | 8 | *updateWaiter 9 | } 10 | 11 | func NewUIDValidityBumped() *UIDValidityBumped { 12 | return &UIDValidityBumped{ 13 | updateWaiter: newUpdateWaiter(), 14 | } 15 | } 16 | 17 | func (u *UIDValidityBumped) String() string { 18 | return fmt.Sprintf("UIDValidityBumped") 19 | } 20 | -------------------------------------------------------------------------------- /internal/response/types.go: -------------------------------------------------------------------------------- 1 | // Package response implements types used when sending IMAP responses back to clients. 2 | package response 3 | 4 | type Response interface { 5 | Send(Session) error 6 | String() string 7 | } 8 | 9 | type Session interface { 10 | WriteResponse(string) error 11 | } 12 | 13 | type mergeableResponse interface { 14 | mergeWith(Response) Response 15 | canSkip(Response) bool 16 | } 17 | -------------------------------------------------------------------------------- /internal/response/item_bodystructure.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemBodyStructure struct { 6 | structure string 7 | } 8 | 9 | func ItemBodyStructure(structure string) *itemBodyStructure { 10 | return &itemBodyStructure{ 11 | structure: structure, 12 | } 13 | } 14 | 15 | func (r *itemBodyStructure) String() string { 16 | return fmt.Sprintf("BODYSTRUCTURE %v", r.structure) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/search_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSearch(t *testing.T) { 10 | assert.Equal( 11 | t, 12 | `* SEARCH 2 3 6`, 13 | Search(2, 3, 6).String(), 14 | ) 15 | } 16 | 17 | func TestSearchEmpty(t *testing.T) { 18 | assert.Equal( 19 | t, 20 | `* SEARCH`, 21 | Search().String(), 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/item_rfc822_header.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemRFC822Header struct { 6 | header []byte 7 | } 8 | 9 | func ItemRFC822Header(header []byte) *itemRFC822Header { 10 | return &itemRFC822Header{ 11 | header: header, 12 | } 13 | } 14 | 15 | func (r *itemRFC822Header) String() string { 16 | return fmt.Sprintf("RFC822.HEADER {%v}\r\n%s", len(r.header), r.header) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/item_uid_validity.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemUIDValidity struct { 10 | val imap.UID 11 | } 12 | 13 | func ItemUIDValidity(n imap.UID) *itemUIDValidity { 14 | return &itemUIDValidity{val: n} 15 | } 16 | 17 | func (c *itemUIDValidity) String() string { 18 | return fmt.Sprintf("UIDVALIDITY %v", c.val) 19 | } 20 | -------------------------------------------------------------------------------- /internal/response/flags_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFlags(t *testing.T) { 11 | assert.Equal( 12 | t, 13 | `* FLAGS (\Answered \Deleted \Draft \Flagged \Seen)`, 14 | Flags().WithFlags(imap.NewFlagSet(`\Answered`, `\Flagged`, `\Deleted`, `\Seen`, `\Draft`)).String(), 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /internal/response/item_rfc822_literal.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemRFC822Literal struct { 6 | literal []byte 7 | } 8 | 9 | func ItemRFC822Literal(literal []byte) *itemRFC822Literal { 10 | return &itemRFC822Literal{ 11 | literal: literal, 12 | } 13 | } 14 | 15 | func (r *itemRFC822Literal) String() string { 16 | return fmt.Sprintf("RFC822 {%v}\r\n%s", len(r.literal), r.literal) 17 | } 18 | -------------------------------------------------------------------------------- /internal/response/fetch_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFetch(t *testing.T) { 11 | assert.Equal( 12 | t, 13 | `* 23 FETCH (FLAGS (\Seen) RFC822.SIZE 44827)`, 14 | Fetch(23). 15 | WithItems(ItemFlags(imap.NewFlagSet(`\Seen`)), ItemRFC822Size(44827)). 16 | String(), 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /internal/response/status_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestStatus(t *testing.T) { 10 | assert.Equal( 11 | t, 12 | `* STATUS "blurdybloop" (MESSAGES 231 UIDNEXT 44292)`, 13 | Status(). 14 | WithMailbox(`blurdybloop`). 15 | WithItems(ItemMessages(231)). 16 | WithItems(ItemUIDNext(44292)). 17 | String(), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /internal/response/item_body_literal_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestItemBodyText(t *testing.T) { 10 | assert.Equal( 11 | t, 12 | "BODY[TEXT] {55}\r\nHello Joe, do you think we can meet at 3:30 tomorrow?\r\n", 13 | ItemBodyLiteral("TEXT", []byte("Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n")).String(), 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /logging/flags_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package logging 4 | 5 | import ( 6 | "runtime" 7 | "runtime/debug" 8 | ) 9 | 10 | // StackKey refers to the stack trace. 11 | const StackKey = "stack" 12 | 13 | func getDefaultLabels(pc uintptr, file string, line int) Labels { 14 | return Labels{ 15 | FnKey: runtime.FuncForPC(pc).Name(), 16 | FileKey: file, 17 | LineKey: line, 18 | StackKey: string(debug.Stack()), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/testdata/space_line_header.eml: -------------------------------------------------------------------------------- 1 | Content-Type: text/plain 2 | Date: Thu, 03 Sep 2020 16:47:43 +0000 (UTC) 3 | Subject: Sometimes 4 | 5 | header fields can be long and contain space line :shrug: 6 | From: Dad 7 | To: Ships 8 | 9 | Why does the Norway navy have bar codes on the side of their ships? 10 | 11 | So when they com back to port they can 12 | 13 | Scandinavian 14 | 15 | -------------------------------------------------------------------------------- /tests/unselect_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestUnselect(t *testing.T) { 8 | runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { 9 | c.C("b001 CREATE saved-messages") 10 | c.S("b001 OK CREATE") 11 | 12 | c.C(`A002 SELECT INBOX`) 13 | c.Se(`A002 OK [READ-WRITE] SELECT`) 14 | 15 | c.C(`A202 UNSELECT`) 16 | c.S("A202 OK UNSELECT") 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /imap/attributes.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | const ( 4 | AttrNoSelect = `\Noselect` 5 | AttrNoInferiors = `\Noinferiors` 6 | AttrMarked = `\Marked` 7 | AttrUnmarked = `\Unmarked` 8 | 9 | // Special Use attributes as defined in RFC-6154. 10 | AttrAll = `\All` 11 | AttrArchive = `\Archive` 12 | AttrDrafts = `\Drafts` 13 | AttrFlagged = `\Flagged` 14 | AttrJunk = `\Junk` 15 | AttrSent = `\Sent` 16 | AttrTrash = `\Trash` 17 | ) 18 | -------------------------------------------------------------------------------- /internal/unleash/unleash.go: -------------------------------------------------------------------------------- 1 | package unleash 2 | 3 | import "github.com/ProtonMail/gluon/imap" 4 | 5 | var CapabilityKillSwitchMap = map[string]string{ 6 | string(imap.IDLE): `InboxBridgeImapIdleCapabilityDisabled`, 7 | } 8 | 9 | type FeatureFlagValueProvider interface { 10 | GetFlagValue(key string) bool 11 | } 12 | 13 | type NullFeatureFlagProvider struct{} 14 | 15 | func (n *NullFeatureFlagProvider) GetFlagValue(_ string) bool { 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /imap/command/idle.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Idle struct{} 10 | 11 | func (l Idle) String() string { 12 | return fmt.Sprintf("IDLE") 13 | } 14 | 15 | func (l Idle) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type IdleCommandParser struct{} 20 | 21 | func (IdleCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Idle{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/noop.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Noop struct{} 10 | 11 | func (l Noop) String() string { 12 | return fmt.Sprintf("NOOP") 13 | } 14 | 15 | func (l Noop) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type NoopCommandParser struct{} 20 | 21 | func (NoopCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Noop{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/item_permanent_flags.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemPermanentFlags struct { 10 | flags imap.FlagSet 11 | } 12 | 13 | func ItemPermanentFlags(flags imap.FlagSet) *itemPermanentFlags { 14 | return &itemPermanentFlags{flags: flags} 15 | } 16 | 17 | func (c *itemPermanentFlags) String() string { 18 | return fmt.Sprintf("PERMANENTFLAGS (%v)", join(c.flags.ToSlice())) 19 | } 20 | -------------------------------------------------------------------------------- /imap/command/check.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Check struct{} 10 | 11 | func (l Check) String() string { 12 | return fmt.Sprintf("CHECK") 13 | } 14 | 15 | func (l Check) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type CheckCommandParser struct{} 20 | 21 | func (CheckCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Check{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/close.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Close struct{} 10 | 11 | func (l Close) String() string { 12 | return fmt.Sprintf("CLOSE") 13 | } 14 | 15 | func (l Close) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type CloseCommandParser struct{} 20 | 21 | func (CloseCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Close{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/id.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "github.com/ProtonMail/gluon/imap" 5 | ) 6 | 7 | type idResponse struct { 8 | imap.IMAPID 9 | } 10 | 11 | func ID(id imap.IMAPID) *idResponse { 12 | return &idResponse{ 13 | IMAPID: id, 14 | } 15 | } 16 | 17 | func (id *idResponse) String() string { 18 | return "* ID " + id.IMAPID.String() 19 | } 20 | 21 | func (r *idResponse) Send(session Session) error { 22 | return session.WriteResponse(r.String()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/logout.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Logout struct{} 10 | 11 | func (l Logout) String() string { 12 | return fmt.Sprintf("LOGOUT") 13 | } 14 | 15 | func (l Logout) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type LogoutCommandParser struct{} 20 | 21 | func (LogoutCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Logout{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/db_impl/db_impl.go: -------------------------------------------------------------------------------- 1 | package db_impl 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/db" 7 | "github.com/ProtonMail/gluon/internal/db_impl/sqlite3" 8 | ) 9 | 10 | func NewSQLiteDB(options ...sqlite3.Option) db.ClientInterface { 11 | return sqlite3.NewBuilder(options...) 12 | } 13 | 14 | func TestUpdateDBVersion(ctx context.Context, dbPath, userID string, version int) error { 15 | return sqlite3.TestUpdateDBVersion(ctx, dbPath, userID, version) 16 | } 17 | -------------------------------------------------------------------------------- /internal/response/expunge.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type expunge struct { 10 | seq imap.SeqID 11 | } 12 | 13 | func Expunge(seq imap.SeqID) *expunge { 14 | return &expunge{ 15 | seq: seq, 16 | } 17 | } 18 | 19 | func (r *expunge) Send(s Session) error { 20 | return s.WriteResponse(r.String()) 21 | } 22 | 23 | func (r *expunge) String() string { 24 | return fmt.Sprintf("* %v EXPUNGE", r.seq) 25 | } 26 | -------------------------------------------------------------------------------- /reporter/reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | type Context = map[string]any 4 | 5 | // Reporter represents an external reporting tool which can be hooked into gluon to report key information and/or 6 | // unexpected behaviors. 7 | type Reporter interface { 8 | ReportException(any) error 9 | ReportMessage(string) error 10 | ReportMessageWithContext(string, Context) error 11 | ReportWarningWithContext(string, Context) error 12 | ReportExceptionWithContext(any, Context) error 13 | } 14 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/benchmark" 5 | _ "github.com/ProtonMail/gluon/benchmarks/gluon_bench/gluon_benchmarks" 6 | _ "github.com/ProtonMail/gluon/benchmarks/gluon_bench/imap_benchmarks" 7 | _ "github.com/ProtonMail/gluon/benchmarks/gluon_bench/store_benchmarks" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func main() { 12 | logrus.SetLevel(logrus.ErrorLevel) 13 | benchmark.RunMain() 14 | } 15 | -------------------------------------------------------------------------------- /imap/command/expunge.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Expunge struct{} 10 | 11 | func (l Expunge) String() string { 12 | return fmt.Sprintf("EXPUNGE") 13 | } 14 | 15 | func (l Expunge) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type ExpungeCommandParser struct{} 20 | 21 | func (ExpungeCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Expunge{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/bad_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBadUntagged(t *testing.T) { 11 | assert.Equal(t, "* BAD", Bad().String()) 12 | } 13 | 14 | func TestBadTagged(t *testing.T) { 15 | assert.Equal(t, "tag BAD", Bad("tag").String()) 16 | } 17 | 18 | func TestBadError(t *testing.T) { 19 | assert.Equal(t, "tag BAD erroooooor", Bad("tag").WithError(errors.New("erroooooor")).String()) 20 | } 21 | -------------------------------------------------------------------------------- /internal/response/item_internal_date.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type itemInternalDate struct { 9 | date time.Time 10 | } 11 | 12 | const internalDateFormat = "02-Jan-2006 15:04:05 -0700" 13 | 14 | func ItemInternalDate(date time.Time) *itemInternalDate { 15 | return &itemInternalDate{date: date} 16 | } 17 | 18 | func (c *itemInternalDate) String() string { 19 | return fmt.Sprintf("INTERNALDATE \"%v\"", c.date.UTC().Format(internalDateFormat)) 20 | } 21 | -------------------------------------------------------------------------------- /internal/session/init.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | ) 7 | 8 | const ( 9 | logIMAPLineLimit = "GLUON_LOG_IMAP_LINE_LIMIT" 10 | responseChannelBufferCount = "GLUON_RESPONSE_CHANNEL_BUFFER_COUNT" 11 | ) 12 | 13 | var maxLineLength = 120 14 | 15 | func init() { 16 | if val, ok := os.LookupEnv(logIMAPLineLimit); ok { 17 | valNum, err := strconv.Atoi(val) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | maxLineLength = valNum 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/check_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/emersion/go-imap/client" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCheck(t *testing.T) { 11 | runOneToOneTestClientWithAuth(t, defaultServerOptions(t), func(client *client.Client, _ *testSession) { 12 | mailboxStatus, err := client.Select("INBOX", false) 13 | require.NoError(t, err) 14 | require.False(t, mailboxStatus.ReadOnly) 15 | require.NoError(t, client.Check()) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /imap/command/starttls.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type StartTLS struct{} 10 | 11 | func (l StartTLS) String() string { 12 | return fmt.Sprintf("STARTTLS") 13 | } 14 | 15 | func (l StartTLS) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type StartTLSCommandParser struct{} 20 | 21 | func (StartTLSCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &StartTLS{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/unselect.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Unselect struct{} 10 | 11 | func (l Unselect) String() string { 12 | return fmt.Sprintf("UNSELECT") 13 | } 14 | 15 | func (l Unselect) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type UnselectCommandParser struct{} 20 | 21 | func (UnselectCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Unselect{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /db/ops_subscription.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type SubscriptionReadOps interface { 10 | GetDeletedSubscriptionSet(ctx context.Context) (map[imap.MailboxID]*DeletedSubscription, error) 11 | } 12 | 13 | type SubscriptionWriteOps interface { 14 | AddDeletedSubscription(ctx context.Context, mboxName string, mboxID imap.MailboxID) error 15 | RemoveDeletedSubscriptionWithName(ctx context.Context, mboxName string) (int, error) 16 | } 17 | -------------------------------------------------------------------------------- /imap/update_message_deleted.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type MessageDeleted struct { 8 | updateBase 9 | 10 | *updateWaiter 11 | 12 | MessageID MessageID 13 | } 14 | 15 | func NewMessagesDeleted(messageID MessageID) *MessageDeleted { 16 | return &MessageDeleted{ 17 | updateWaiter: newUpdateWaiter(), 18 | MessageID: messageID, 19 | } 20 | } 21 | 22 | func (u *MessageDeleted) String() string { 23 | return fmt.Sprintf("MessageDeleted ID=%v", u.MessageID.ShortID()) 24 | } 25 | -------------------------------------------------------------------------------- /internal/response/capability_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCapabilityUntagged(t *testing.T) { 11 | assert.Equal(t, "* CAPABILITY IMAP4rev1", Capability().WithCapabilities(imap.IMAP4rev1).String()) 12 | } 13 | 14 | func TestCapabilityExtras(t *testing.T) { 15 | assert.Equal(t, "* CAPABILITY IDLE IMAP4rev1", Capability().WithCapabilities(imap.IMAP4rev1, imap.IDLE).String()) 16 | } 17 | -------------------------------------------------------------------------------- /internal/session/responses.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/internal/response" 7 | "github.com/ProtonMail/gluon/internal/state" 8 | ) 9 | 10 | func flush(ctx context.Context, mailbox state.AppendOnlyMailbox, permitExpunge bool, resCh chan response.Response) error { 11 | res, err := mailbox.Flush(ctx, permitExpunge) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | for _, res := range res { 17 | resCh <- res 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /imap/command/capability.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Capability struct{} 10 | 11 | func (l Capability) String() string { 12 | return fmt.Sprintf("CAPABILITY") 13 | } 14 | 15 | func (l Capability) SanitizedString() string { 16 | return l.String() 17 | } 18 | 19 | type CapabilityCommandParser struct{} 20 | 21 | func (CapabilityCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 22 | return &Capability{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /db/ops.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "context" 4 | 5 | type ReadOnly interface { 6 | MailboxReadOps 7 | MessageReadOps 8 | SubscriptionReadOps 9 | 10 | // GetConnectorSettings returns true if no previous setting was ever stored before. 11 | GetConnectorSettings(ctx context.Context) (string, bool, error) 12 | } 13 | 14 | type Transaction interface { 15 | ReadOnly 16 | MailboxWriteOps 17 | MessageWriteOps 18 | SubscriptionWriteOps 19 | 20 | StoreConnectorSettings(ctx context.Context, settings string) error 21 | } 22 | -------------------------------------------------------------------------------- /internal/response/item_appenduid.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemAppendUID struct { 10 | uidValidity, messageUID imap.UID 11 | } 12 | 13 | func ItemAppendUID(uidValidity, messageUID imap.UID) *itemAppendUID { 14 | return &itemAppendUID{ 15 | uidValidity: uidValidity, 16 | messageUID: messageUID, 17 | } 18 | } 19 | 20 | func (c *itemAppendUID) String() string { 21 | return fmt.Sprintf("APPENDUID %v %v", c.uidValidity, c.messageUID) 22 | } 23 | -------------------------------------------------------------------------------- /internal/response/item_uid.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemUID struct { 10 | uid imap.UID 11 | } 12 | 13 | func ItemUID(n imap.UID) *itemUID { 14 | return &itemUID{uid: n} 15 | } 16 | 17 | func (c *itemUID) String() string { 18 | return fmt.Sprintf("UID %v", c.uid) 19 | } 20 | 21 | func (c *itemUID) mergeWith(other Item) Item { 22 | _, ok := other.(*itemUID) 23 | if !ok { 24 | return nil 25 | } 26 | 27 | return ItemUID(c.uid) 28 | } 29 | -------------------------------------------------------------------------------- /async/wait_group.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import "sync" 4 | 5 | type WaitGroup struct { 6 | wg sync.WaitGroup 7 | panicHandler PanicHandler 8 | } 9 | 10 | func MakeWaitGroup(panicHandler PanicHandler) WaitGroup { 11 | return WaitGroup{panicHandler: panicHandler} 12 | } 13 | 14 | func (wg *WaitGroup) Go(f func()) { 15 | wg.wg.Add(1) 16 | 17 | go func() { 18 | defer HandlePanic(wg.panicHandler) 19 | 20 | defer wg.wg.Done() 21 | f() 22 | }() 23 | } 24 | 25 | func (wg *WaitGroup) Wait() { 26 | wg.wg.Wait() 27 | } 28 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type Store interface { 10 | Get(messageID imap.InternalMessageID) ([]byte, error) 11 | Set(messageID imap.InternalMessageID, reader io.Reader) error 12 | Delete(messageID ...imap.InternalMessageID) error 13 | Close() error 14 | List() ([]imap.InternalMessageID, error) 15 | } 16 | 17 | type Builder interface { 18 | New(dir, userID string, passphrase []byte) (Store, error) 19 | Delete(dir, userID string) error 20 | } 21 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // Package gluon implements an IMAP4rev1 (+ extensions) mailserver. 2 | package gluon 3 | 4 | import ( 5 | "errors" 6 | 7 | "github.com/ProtonMail/gluon/internal/state" 8 | ) 9 | 10 | // IsNoSuchMessage returns true if the error is ErrNoSuchMessage. 11 | func IsNoSuchMessage(err error) bool { 12 | return errors.Is(err, state.ErrNoSuchMessage) 13 | } 14 | 15 | // IsNoSuchMailbox returns true if the error is ErrNoSuchMailbox. 16 | func IsNoSuchMailbox(err error) bool { 17 | return errors.Is(err, state.ErrNoSuchMailbox) 18 | } 19 | -------------------------------------------------------------------------------- /reporter/context.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | import "context" 4 | 5 | type reporterKeyType struct{} 6 | 7 | var reporterKeyVal reporterKeyType 8 | 9 | func NewContextWithReporter(ctx context.Context, reporter Reporter) context.Context { 10 | return context.WithValue(ctx, reporterKeyVal, reporter) 11 | } 12 | 13 | func GetReporterFromContext(ctx context.Context) (Reporter, bool) { 14 | v := ctx.Value(reporterKeyVal) 15 | if v == nil { 16 | return nil, false 17 | } 18 | 19 | rep, ok := v.(Reporter) 20 | 21 | return rep, ok 22 | } 23 | -------------------------------------------------------------------------------- /async/panic_handler.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | type PanicHandler interface { 4 | HandlePanic(interface{}) 5 | } 6 | 7 | type NoopPanicHandler struct{} 8 | 9 | func (n NoopPanicHandler) HandlePanic(r interface{}) {} 10 | 11 | func HandlePanic(panicHandler PanicHandler) { 12 | if panicHandler == nil { 13 | return 14 | } 15 | 16 | if _, ok := panicHandler.(NoopPanicHandler); ok { 17 | return 18 | } 19 | 20 | if _, ok := panicHandler.(*NoopPanicHandler); ok { 21 | return 22 | } 23 | 24 | panicHandler.HandlePanic(recover()) 25 | } 26 | -------------------------------------------------------------------------------- /imap/command/mailbox.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | // ParseMailbox parses a mailbox name as defined in RFC 3501. 10 | func ParseMailbox(p *rfcparser.Parser) (rfcparser.String, error) { 11 | // mailbox = "INBOX" / astring 12 | astring, err := p.ParseAString() 13 | if err != nil { 14 | return rfcparser.String{}, err 15 | } 16 | 17 | if strings.EqualFold(astring.Value, "INBOX") { 18 | astring.Value = "INBOX" 19 | } 20 | 21 | return astring, nil 22 | } 23 | -------------------------------------------------------------------------------- /rfc5322/quoted_test.go: -------------------------------------------------------------------------------- 1 | package rfc5322 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQuotedString(t *testing.T) { 10 | inputs := map[string]string{ 11 | `"f\".c"`: "f\".c", 12 | "\" \r\n f\\\".c\r\n \"": " f\".c ", 13 | ` " foo bar derer " `: " foo bar derer ", 14 | } 15 | 16 | for i, e := range inputs { 17 | p := newTestRFCParser(i) 18 | v, err := parseQuotedString(p) 19 | require.NoError(t, err) 20 | require.Equal(t, e, v.String.Value) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/response/continuation.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "strings" 4 | 5 | type continuation struct { 6 | tag string 7 | } 8 | 9 | func Continuation() *continuation { 10 | return &continuation{ 11 | tag: "+", 12 | } 13 | } 14 | 15 | func (r *continuation) Send(s Session, message string) error { 16 | return s.WriteResponse(r.String(message)) 17 | } 18 | 19 | func (r *continuation) String(message string) string { 20 | if len(message) == 0 { 21 | return r.tag 22 | } 23 | 24 | return strings.Join([]string{r.tag, message}, " ") 25 | } 26 | -------------------------------------------------------------------------------- /reporter/null_reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | type NullReporter struct{} 4 | 5 | func (*NullReporter) ReportException(any) error { 6 | return nil 7 | } 8 | 9 | func (*NullReporter) ReportMessage(string) error { 10 | return nil 11 | } 12 | 13 | func (*NullReporter) ReportMessageWithContext(string, Context) error { 14 | return nil 15 | } 16 | 17 | func (*NullReporter) ReportWarningWithContext(string, Context) error { 18 | return nil 19 | } 20 | 21 | func (*NullReporter) ReportExceptionWithContext(any, Context) error { 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /imap/mailbox.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | type Mailbox struct { 4 | ID MailboxID 5 | 6 | Name []string 7 | 8 | Flags, PermanentFlags, Attributes FlagSet 9 | } 10 | 11 | type MailboxNoAttrib struct { 12 | ID MailboxID 13 | 14 | Name []string 15 | } 16 | 17 | const Inbox = "INBOX" 18 | 19 | type MailboxVisibility int 20 | 21 | const ( 22 | Hidden MailboxVisibility = iota 23 | Visible 24 | HiddenIfEmpty 25 | ) 26 | 27 | type MailboxData struct { 28 | InternalID InternalMailboxID 29 | RemoteID string 30 | GluonName string 31 | BridgeName []string 32 | } 33 | -------------------------------------------------------------------------------- /internal/session/logger.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "strings" 8 | ) 9 | 10 | func writeLog(w io.Writer, leader, sessionID, line string) { 11 | line = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(line), "\r", `\r`), "\n", `\n`), "\t", `\t`) 12 | 13 | if len(line) > maxLineLength { 14 | line = line[:maxLineLength] + "..." 15 | } 16 | 17 | if _, err := fmt.Fprintf(w, "%v[%v]: %v\n", leader, sessionID, line); err != nil { 18 | log.Printf("gluon: failed to write log: %v", err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/reporter/stdout_reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // StdOutReporter prints the benchmark report to os.Stdout. 8 | type StdOutReporter struct{} 9 | 10 | func (*StdOutReporter) ProduceReport(reports []*BenchmarkReport) error { 11 | for i, v := range reports { 12 | fmt.Printf("[%02d] Benchmark %v\n", i, v.Name) 13 | fmt.Printf("[%02d] %v\n", i, v.Statistics.String()) 14 | 15 | for r, v := range v.Runs { 16 | fmt.Printf("[%02d] Run %02d - %v\n", i, r, v.String()) 17 | } 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/store_benchmarks/store.go: -------------------------------------------------------------------------------- 1 | package store_benchmarks 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/flags" 7 | "github.com/ProtonMail/gluon/store" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type OnDiskStoreBuilder struct{} 12 | 13 | func (*OnDiskStoreBuilder) New(path string) (store.Store, error) { 14 | return store.NewOnDiskStore(filepath.Join(path, uuid.NewString()), []byte(*flags.UserPassword)) 15 | } 16 | 17 | func init() { 18 | RegisterStoreBuilder("default", &OnDiskStoreBuilder{}) 19 | } 20 | -------------------------------------------------------------------------------- /rfc822/hash_test.go: -------------------------------------------------------------------------------- 1 | package rfc822 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetMessageHashSameBodyDifferentTextEncodings(t *testing.T) { 11 | data1, err := os.ReadFile("testdata/hash_quoted.eml") 12 | require.NoError(t, err) 13 | 14 | data2, err := os.ReadFile("testdata/hash_utf8.eml") 15 | require.NoError(t, err) 16 | 17 | h1, err := GetMessageHash(data1) 18 | require.NoError(t, err) 19 | 20 | h2, err := GetMessageHash(data2) 21 | require.NoError(t, err) 22 | 23 | require.Equal(t, h1, h2) 24 | } 25 | -------------------------------------------------------------------------------- /store/fallback.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "crypto/cipher" 5 | "io" 6 | ) 7 | 8 | // Fallback provides an interface to supply an alternative way to read a store file should the main route fail. 9 | // This is mainly intended to allow users of the library to read old store formats they may have kept on disk. 10 | // This is a stop-gap until a complete data migration cycle can be implemented in gluon. 11 | type Fallback interface { 12 | Read(gcm cipher.AEAD, reader io.Reader) ([]byte, error) 13 | 14 | Write(gcm cipher.AEAD, filepath string, data []byte) error 15 | } 16 | -------------------------------------------------------------------------------- /tests/multi_user_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMultiUser(t *testing.T) { 8 | runTest(t, defaultServerOptions(t, withCredentials([]credentials{ 9 | {usernames: []string{"user1"}, password: "pass"}, 10 | {usernames: []string{"user2"}, password: "pass"}, 11 | })), []int{1, 2}, func(c map[int]*testConnection, s *testSession) { 12 | c[1].C(`A001 login user1 pass`).OK(`A001`) 13 | c[2].C(`B001 login user2 pass`).OK(`B001`) 14 | 15 | c[1].C(`A002 select inbox`).OK(`A002`) 16 | c[2].C(`B002 select inbox`).OK(`B002`) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/flags/store_benchmarks.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import "flag" 4 | 5 | var ( 6 | Store = flag.String("store", "default", "Name of the storage implementation to benchmark. Defaults to regular on disk storage by default.") 7 | StoreWorkers = flag.Uint("store-workers", 1, "Number of concurrent workers for store operations.") 8 | StoreItemCount = flag.Uint("store-item-count", 1000, "Number of items to generate in the store benchmarks.") 9 | StoreItemSize = flag.Uint("store-item-size", 15*1024*1024, "Number of items to generate in the store benchmarks.") 10 | ) 11 | -------------------------------------------------------------------------------- /internal/response/flags.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type flags struct { 10 | flags imap.FlagSet 11 | } 12 | 13 | func Flags() *flags { 14 | return &flags{flags: imap.NewFlagSet()} 15 | } 16 | 17 | func (r *flags) WithFlags(fs imap.FlagSet) *flags { 18 | r.flags.AddFlagSetToSelf(fs) 19 | return r 20 | } 21 | 22 | func (r *flags) Send(s Session) error { 23 | return s.WriteResponse(r.String()) 24 | } 25 | 26 | func (r *flags) String() string { 27 | return fmt.Sprintf("* FLAGS (%v)", join(r.flags.ToSlice())) 28 | } 29 | -------------------------------------------------------------------------------- /internal/response/item_flags.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemFlags struct { 10 | flags imap.FlagSet 11 | } 12 | 13 | func ItemFlags(flags imap.FlagSet) *itemFlags { 14 | return &itemFlags{flags: flags} 15 | } 16 | 17 | func (c *itemFlags) String() string { 18 | return fmt.Sprintf("FLAGS (%v)", join(c.flags.ToSlice())) 19 | } 20 | 21 | func (c *itemFlags) mergeWith(other Item) Item { 22 | _, ok := other.(*itemFlags) 23 | if !ok { 24 | return nil 25 | } 26 | 27 | return ItemFlags(c.flags.Clone()) 28 | } 29 | -------------------------------------------------------------------------------- /store/option.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | type Option interface { 4 | config(*onDiskStore) 5 | } 6 | 7 | func WithSemaphore(sem *Semaphore) Option { 8 | return &withSem{ 9 | sem: sem, 10 | } 11 | } 12 | 13 | type withSem struct { 14 | sem *Semaphore 15 | } 16 | 17 | func (opt withSem) config(store *onDiskStore) { 18 | store.sem = opt.sem 19 | } 20 | 21 | type withFallback struct { 22 | f Fallback 23 | } 24 | 25 | func WithFallback(f Fallback) Option { 26 | return &withFallback{f: f} 27 | } 28 | 29 | func (opt withFallback) config(store *onDiskStore) { 30 | store.fallback = opt.f 31 | } 32 | -------------------------------------------------------------------------------- /tests/db_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/db" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func dbCheckUserMessageCount(s *testSession, user string, expectedCount int) { 11 | err := s.withUserDB(user, func(ent db.Client, ctx context.Context) { 12 | val, err := db.ClientReadType(ctx, ent, func(ctx context.Context, only db.ReadOnly) (int, error) { 13 | return only.GetTotalMessageCount(ctx) 14 | }) 15 | require.NoError(s.tb, err) 16 | require.Equal(s.tb, expectedCount, val) 17 | }) 18 | require.NoError(s.tb, err) 19 | } 20 | -------------------------------------------------------------------------------- /async/bool.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import "sync/atomic" 4 | 5 | // atomicBool is an atomic boolean value. 6 | // The zero value is false. 7 | type atomicBool struct { 8 | v uint32 9 | } 10 | 11 | // Load atomically loads and returns the value stored in x. 12 | func (x *atomicBool) load() bool { return atomic.LoadUint32(&x.v) != 0 } 13 | 14 | // Store atomically stores val into x. 15 | func (x *atomicBool) store(val bool) { atomic.StoreUint32(&x.v, b32(val)) } 16 | 17 | // b32 returns a uint32 0 or 1 representing b. 18 | func b32(b bool) uint32 { 19 | if b { 20 | return 1 21 | } 22 | 23 | return 0 24 | } 25 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/reporter/json_reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | ) 7 | 8 | // JSONReporter produces a JSON data file with all the benchmark information. 9 | type JSONReporter struct { 10 | outputPath string 11 | } 12 | 13 | func (j *JSONReporter) ProduceReport(reports []*BenchmarkReport) error { 14 | result, err := json.Marshal(reports) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | return os.WriteFile(j.outputPath, result, 0o600) 20 | } 21 | 22 | func NewJSONReporter(output string) *JSONReporter { 23 | return &JSONReporter{outputPath: output} 24 | } 25 | -------------------------------------------------------------------------------- /imap/command/idle_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_IdleCommand(t *testing.T) { 12 | input := toIMAPLine(`tag IDLE`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Idle{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "idle", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/noop_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_NoopCommand(t *testing.T) { 12 | input := toIMAPLine(`tag NOOP`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Noop{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "noop", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/update_mailbox_created.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type MailboxCreated struct { 9 | updateBase 10 | 11 | *updateWaiter 12 | 13 | Mailbox Mailbox 14 | } 15 | 16 | func NewMailboxCreated(mailbox Mailbox) *MailboxCreated { 17 | return &MailboxCreated{ 18 | updateWaiter: newUpdateWaiter(), 19 | Mailbox: mailbox, 20 | } 21 | } 22 | 23 | func (u *MailboxCreated) String() string { 24 | return fmt.Sprintf( 25 | "MailboxCreated: Mailbox.ID = %v, Mailbox.Name = %v", 26 | u.Mailbox.ID.ShortID(), 27 | ShortID(strings.Join(u.Mailbox.Name, "/")), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /observability/context.go: -------------------------------------------------------------------------------- 1 | package observability 2 | 3 | import "context" 4 | 5 | type observabilitySenderKeyType struct{} 6 | 7 | var observabilitySenderKeyVal observabilitySenderKeyType 8 | 9 | func NewContextWithObservabilitySender(ctx context.Context, sender Sender) context.Context { 10 | return context.WithValue(ctx, observabilitySenderKeyVal, sender) 11 | } 12 | 13 | func getObservabilitySenderFromContext(ctx context.Context) (Sender, bool) { 14 | v := ctx.Value(observabilitySenderKeyVal) 15 | if v == nil { 16 | return nil, false 17 | } 18 | 19 | sender, ok := v.(Sender) 20 | 21 | return sender, ok 22 | } 23 | -------------------------------------------------------------------------------- /imap/command/check_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_CheckCommand(t *testing.T) { 12 | input := toIMAPLine(`tag CHECK`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Check{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "check", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/close_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_CloseCommand(t *testing.T) { 12 | input := toIMAPLine(`tag CLOSE`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Close{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "close", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/list_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestList(t *testing.T) { 11 | assert.Equal( 12 | t, 13 | `* LIST (\Noselect) "/" "~/Mail/foo"`, 14 | List().WithAttributes(imap.NewFlagSet(`\Noselect`)).WithDelimiter("/").WithName(`~/Mail/foo`).String(), 15 | ) 16 | } 17 | 18 | func TestListNilDelimiter(t *testing.T) { 19 | assert.Equal( 20 | t, 21 | `* LIST (\Noselect) NIL "Mail"`, 22 | List().WithAttributes(imap.NewFlagSet(`\Noselect`)).WithName(`Mail`).String(), 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /internal/response/lsub_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestLsub(t *testing.T) { 11 | assert.Equal( 12 | t, 13 | `* LSUB (\Noselect) "/" "~/Mail/foo"`, 14 | Lsub().WithAttributes(imap.NewFlagSet(`\Noselect`)).WithDelimiter("/").WithName(`~/Mail/foo`).String(), 15 | ) 16 | } 17 | 18 | func TestLsubNilDelimiter(t *testing.T) { 19 | assert.Equal( 20 | t, 21 | `* LSUB (\Noselect) NIL "Mail"`, 22 | Lsub().WithAttributes(imap.NewFlagSet(`\Noselect`)).WithName(`Mail`).String(), 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /imap/command/logout_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_LogoutCommand(t *testing.T) { 12 | input := toIMAPLine(`tag LOGOUT`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Logout{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "logout", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/nstring.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import "github.com/ProtonMail/gluon/rfcparser" 4 | 5 | // ParseNString pareses a string or NIL. If NIL was parsed the boolean return is set to false. 6 | func ParseNString(p *rfcparser.Parser) (rfcparser.String, bool, error) { 7 | // nstring = string / nil 8 | if s, ok, err := p.TryParseString(); err != nil { 9 | return rfcparser.String{}, false, err 10 | } else if ok { 11 | return s, false, nil 12 | } 13 | 14 | if err := p.ConsumeBytesFold('N', 'I', 'L'); err != nil { 15 | return rfcparser.String{}, false, err 16 | } 17 | 18 | return rfcparser.String{}, true, nil 19 | } 20 | -------------------------------------------------------------------------------- /imap/capabilities.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | type Capability string 4 | 5 | const ( 6 | IMAP4rev1 Capability = `IMAP4rev1` 7 | StartTLS Capability = `STARTTLS` 8 | IDLE Capability = `IDLE` 9 | UNSELECT Capability = `UNSELECT` 10 | UIDPLUS Capability = `UIDPLUS` 11 | MOVE Capability = `MOVE` 12 | ID Capability = `ID` 13 | AUTHPLAIN Capability = `AUTH=PLAIN` 14 | ) 15 | 16 | func IsCapabilityAvailableBeforeAuth(c Capability) bool { 17 | switch c { 18 | case IMAP4rev1, StartTLS, IDLE, ID, AUTHPLAIN: 19 | return true 20 | case UNSELECT, UIDPLUS, MOVE: 21 | return false 22 | } 23 | 24 | return false 25 | } 26 | -------------------------------------------------------------------------------- /imap/command/expunge_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_ExpungeCommand(t *testing.T) { 12 | input := toIMAPLine(`tag EXPUNGE`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Expunge{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "expunge", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/starttls_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_StartTLSCommand(t *testing.T) { 12 | input := toIMAPLine(`tag STARTTLS`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &StartTLS{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "starttls", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/unselect_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_UnselectCommand(t *testing.T) { 12 | input := toIMAPLine(`tag UNSELECT`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Unselect{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "unselect", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/item_capability.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "golang.org/x/exp/slices" 8 | ) 9 | 10 | type itemCapability struct { 11 | caps []imap.Capability 12 | } 13 | 14 | func ItemCapability(caps ...imap.Capability) *itemCapability { 15 | return &itemCapability{ 16 | caps: caps, 17 | } 18 | } 19 | 20 | func (r *itemCapability) String() string { 21 | var caps []string 22 | 23 | for _, capability := range r.caps { 24 | caps = append(caps, string(capability)) 25 | } 26 | 27 | slices.Sort(caps) 28 | 29 | return fmt.Sprintf("CAPABILITY %v", join(caps)) 30 | } 31 | -------------------------------------------------------------------------------- /observability/observability.go: -------------------------------------------------------------------------------- 1 | package observability 2 | 3 | var imapErrorMetricType int 4 | var messageErrorMetricType int 5 | var otherErrorMetricType int 6 | 7 | type Sender interface { 8 | AddMetrics(metrics ...map[string]interface{}) 9 | AddDistinctMetrics(errType interface{}, metrics ...map[string]interface{}) 10 | AddIMAPConnectionsExceededThresholdMetric(totalOpenIMAPConnections, newIMAPConnections int) 11 | } 12 | 13 | func SetupMetricTypes(imapErrorType, messageErrorType, otherErrorType int) { 14 | imapErrorMetricType = imapErrorType 15 | messageErrorMetricType = messageErrorType 16 | otherErrorMetricType = otherErrorType 17 | } 18 | -------------------------------------------------------------------------------- /profiling/context.go: -------------------------------------------------------------------------------- 1 | package profiling 2 | 3 | import "context" 4 | 5 | type withProfilerType struct{} 6 | 7 | var withProfilerKey withProfilerType 8 | 9 | func Start(ctx context.Context, cmdType int) { 10 | if profiler, ok := ctx.Value(withProfilerKey).(CmdProfiler); ok { 11 | profiler.Start(cmdType) 12 | } 13 | } 14 | 15 | func Stop(ctx context.Context, cmdType int) { 16 | if profiler, ok := ctx.Value(withProfilerKey).(CmdProfiler); ok { 17 | profiler.Stop(cmdType) 18 | } 19 | } 20 | 21 | func WithProfiler(ctx context.Context, profiler CmdProfiler) context.Context { 22 | return context.WithValue(ctx, withProfilerKey, profiler) 23 | } 24 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/imap_benchmarks/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/ProtonMail/gluon/profiling" 8 | ) 9 | 10 | type Server interface { 11 | // Close should close all server connections or shut down the server. 12 | Close(ctx context.Context) error 13 | 14 | // Address should return the server address. 15 | Address() net.Addr 16 | } 17 | 18 | type ServerBuilder interface { 19 | // New Create new Server instance at a given path and use the command profiler, if possible. 20 | New(ctx context.Context, serverPath string, profiler profiling.CmdProfilerBuilder) (Server, error) 21 | } 22 | -------------------------------------------------------------------------------- /imap/command/capability_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_CapabilityCommand(t *testing.T) { 12 | input := toIMAPLine(`tag CAPABILITY`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Capability{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "capability", p.LastParsedCommand()) 22 | require.Equal(t, "tag", p.LastParsedTag()) 23 | } 24 | -------------------------------------------------------------------------------- /internal/response/item_copyuid.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type itemCopyUID struct { 10 | uidValidity imap.UID 11 | sourceSet, destSet imap.SeqSet 12 | } 13 | 14 | func ItemCopyUID(uidValidity imap.UID, sourceSet, destSet []imap.UID) *itemCopyUID { 15 | return &itemCopyUID{ 16 | uidValidity: uidValidity, 17 | sourceSet: imap.NewSeqSetFromUID(sourceSet), 18 | destSet: imap.NewSeqSetFromUID(destSet), 19 | } 20 | } 21 | 22 | func (c *itemCopyUID) String() string { 23 | return fmt.Sprintf("COPYUID %v %v %v", c.uidValidity, c.sourceSet, c.destSet) 24 | } 25 | -------------------------------------------------------------------------------- /internal/session/flags.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | var ErrFlagRecentIsReserved = errors.New(`system flag \Recent is reserved`) 10 | 11 | // validateStoreFlags ensures that the given flags are valid for a STORE command and return them as an imap.FlagSet. 12 | func validateStoreFlags(flags []string) (imap.FlagSet, error) { 13 | flagSet := imap.NewFlagSetFromSlice(flags) 14 | 15 | // As per RFC 3501, section 2.3.2, changing the \Recent flag is forbidden. 16 | if flagSet.Contains(imap.FlagRecent) { 17 | return nil, ErrFlagRecentIsReserved 18 | } 19 | 20 | return flagSet, nil 21 | } 22 | -------------------------------------------------------------------------------- /imap/command/create_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_CreateCommand(t *testing.T) { 12 | input := toIMAPLine(`tag CREATE INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Create{ 17 | Mailbox: "INBOX", 18 | }} 19 | 20 | cmd, err := p.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, expected, cmd) 23 | require.Equal(t, "create", p.LastParsedCommand()) 24 | require.Equal(t, "tag", p.LastParsedTag()) 25 | } 26 | -------------------------------------------------------------------------------- /imap/command/delete_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_DeleteCommand(t *testing.T) { 12 | input := toIMAPLine(`tag DELETE INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Delete{ 17 | Mailbox: "INBOX", 18 | }} 19 | 20 | cmd, err := p.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, expected, cmd) 23 | require.Equal(t, "delete", p.LastParsedCommand()) 24 | require.Equal(t, "tag", p.LastParsedTag()) 25 | } 26 | -------------------------------------------------------------------------------- /imap/command/select_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_SelectCommand(t *testing.T) { 12 | input := toIMAPLine(`tag SELECT INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Select{ 17 | Mailbox: "INBOX", 18 | }} 19 | 20 | cmd, err := p.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, expected, cmd) 23 | require.Equal(t, "select", p.LastParsedCommand()) 24 | require.Equal(t, "tag", p.LastParsedTag()) 25 | } 26 | -------------------------------------------------------------------------------- /imap/update_mailbox_id_changed.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type MailboxIDChanged struct { 8 | updateBase 9 | 10 | *updateWaiter 11 | 12 | InternalID InternalMailboxID 13 | RemoteID MailboxID 14 | } 15 | 16 | func NewMailboxIDChanged(internalID InternalMailboxID, remoteID MailboxID) *MailboxIDChanged { 17 | return &MailboxIDChanged{ 18 | updateWaiter: newUpdateWaiter(), 19 | InternalID: internalID, 20 | RemoteID: remoteID, 21 | } 22 | } 23 | 24 | func (u *MailboxIDChanged) String() string { 25 | return fmt.Sprintf("MailboxIDChanged: InternalID = %v, RemoteID = %v", u.InternalID.ShortID(), u.RemoteID.ShortID()) 26 | } 27 | -------------------------------------------------------------------------------- /imap/update_message_id_changed.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type MessageIDChanged struct { 8 | updateBase 9 | 10 | *updateWaiter 11 | 12 | InternalID InternalMessageID 13 | RemoteID MessageID 14 | } 15 | 16 | func NewMessageIDChanged(internalID InternalMessageID, remoteID MessageID) *MessageIDChanged { 17 | return &MessageIDChanged{ 18 | updateWaiter: newUpdateWaiter(), 19 | InternalID: internalID, 20 | RemoteID: remoteID, 21 | } 22 | } 23 | 24 | func (u *MessageIDChanged) String() string { 25 | return fmt.Sprintf("MessageID changed: InternalID = %v, RemoteID = %v", u.InternalID.ShortID(), u.RemoteID.ShortID()) 26 | } 27 | -------------------------------------------------------------------------------- /tests/migration_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/internal/db_impl" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestFailedMigrationRestsDatabase(t *testing.T) { 12 | dbDir := t.TempDir() 13 | serverOptions := defaultServerOptions(t, withDatabaseDir(dbDir)) 14 | 15 | var userID string 16 | 17 | runServer(t, serverOptions, func(session *testSession) { 18 | userID = session.userIDs["user"] 19 | }) 20 | 21 | require.NoError(t, db_impl.TestUpdateDBVersion(context.Background(), dbDir, userID, 99999)) 22 | 23 | runServer(t, serverOptions, func(session *testSession) {}) 24 | } 25 | -------------------------------------------------------------------------------- /imap/command/examine_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_ExamineCommand(t *testing.T) { 12 | input := toIMAPLine(`tag EXAMINE INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Examine{ 17 | Mailbox: "INBOX", 18 | }} 19 | 20 | cmd, err := p.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, expected, cmd) 23 | require.Equal(t, "examine", p.LastParsedCommand()) 24 | require.Equal(t, "tag", p.LastParsedTag()) 25 | } 26 | -------------------------------------------------------------------------------- /imap/update_mailbox_created_or_updated.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type MailboxUpdatedOrCreated struct { 9 | updateBase 10 | 11 | *updateWaiter 12 | 13 | Mailbox Mailbox 14 | } 15 | 16 | func NewMailboxUpdatedOrCreated(mailbox Mailbox) *MailboxUpdatedOrCreated { 17 | return &MailboxUpdatedOrCreated{ 18 | updateWaiter: newUpdateWaiter(), 19 | Mailbox: mailbox, 20 | } 21 | } 22 | 23 | func (u *MailboxUpdatedOrCreated) String() string { 24 | return fmt.Sprintf("MailboxUpdatedOrCreated: Mailbox.ID = %v, Mailbox.Name = %v", 25 | u.Mailbox.ID.ShortID(), 26 | ShortID(strings.Join(u.Mailbox.Name, "/")), 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /imap/update_message_flags_updated.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type MessageFlagsUpdated struct { 8 | updateBase 9 | 10 | *updateWaiter 11 | 12 | MessageID MessageID 13 | Flags FlagSet 14 | } 15 | 16 | func NewMessageFlagsUpdated(messageID MessageID, flags FlagSet) *MessageFlagsUpdated { 17 | return &MessageFlagsUpdated{ 18 | updateWaiter: newUpdateWaiter(), 19 | MessageID: messageID, 20 | Flags: flags, 21 | } 22 | } 23 | 24 | func (u *MessageFlagsUpdated) String() string { 25 | return fmt.Sprintf( 26 | "MessageFlagsUpdated: MessageID = %v, Flags = %v", 27 | u.MessageID.ShortID(), 28 | u.Flags.ToSlice(), 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /internal/response/no_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNoUntagged(t *testing.T) { 11 | assert.Equal(t, "* NO", No().String()) 12 | } 13 | 14 | func TestNoTagged(t *testing.T) { 15 | assert.Equal(t, "tag NO", No("tag").String()) 16 | } 17 | 18 | func TestNoError(t *testing.T) { 19 | assert.Equal(t, "tag NO erroooooor", No("tag").WithError(errors.New("erroooooor")).String()) 20 | } 21 | 22 | func TestNoTryCreate(t *testing.T) { 23 | assert.Equal(t, "tag NO [TRYCREATE] erroooooor", No("tag").WithItems(ItemTryCreate()).WithError(errors.New("erroooooor")).String()) 24 | } 25 | -------------------------------------------------------------------------------- /imap/command/rename_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_RenameCommand(t *testing.T) { 12 | input := toIMAPLine(`tag RENAME Foo Bar`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Rename{ 17 | From: "Foo", 18 | To: "Bar", 19 | }} 20 | 21 | cmd, err := p.Parse() 22 | require.NoError(t, err) 23 | require.Equal(t, expected, cmd) 24 | require.Equal(t, "rename", p.LastParsedCommand()) 25 | require.Equal(t, "tag", p.LastParsedTag()) 26 | } 27 | -------------------------------------------------------------------------------- /imap/command/subscribte_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_SubscribeCommand(t *testing.T) { 12 | input := toIMAPLine(`tag SUBSCRIBE INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Subscribe{ 17 | Mailbox: "INBOX", 18 | }} 19 | 20 | cmd, err := p.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, expected, cmd) 23 | require.Equal(t, "subscribe", p.LastParsedCommand()) 24 | require.Equal(t, "tag", p.LastParsedTag()) 25 | } 26 | -------------------------------------------------------------------------------- /imap/command/unsubscribe_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_UnsubscribeCommand(t *testing.T) { 12 | input := toIMAPLine(`tag UNSUBSCRIBE INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Unsubscribe{ 17 | Mailbox: "INBOX", 18 | }} 19 | 20 | cmd, err := p.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, expected, cmd) 23 | require.Equal(t, "unsubscribe", p.LastParsedCommand()) 24 | require.Equal(t, "tag", p.LastParsedTag()) 25 | } 26 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | go: "1.24" 3 | 4 | linters: 5 | presets: 6 | - bugs 7 | - comment 8 | disable: 9 | - godox # Annoying, we have too many TODOs at the moment :p 10 | - musttag # We do not want to force annotating every field name. 11 | - errorlint # Too many false positives 12 | issues: 13 | exclude-rules: 14 | - path: benchmarks 15 | linters: 16 | - gosec 17 | - dupword 18 | - path: tests 19 | linters: 20 | - dupword 21 | - path: _test\.go 22 | linters: 23 | - dupword 24 | linters-settings: 25 | gosec: 26 | excludes: 27 | - G115 # Potential integer overflow when converting between integer types 28 | -------------------------------------------------------------------------------- /imap/command/login_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_LoginCommandQuoted(t *testing.T) { 12 | input := toIMAPLine(`tag LOGIN "foo" "bar"`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Login{ 17 | UserID: "foo", 18 | Password: "bar", 19 | }} 20 | 21 | cmd, err := p.Parse() 22 | require.NoError(t, err) 23 | require.Equal(t, expected, cmd) 24 | require.Equal(t, "login", p.LastParsedCommand()) 25 | require.Equal(t, "tag", p.LastParsedTag()) 26 | } 27 | -------------------------------------------------------------------------------- /imap/update_mailbox_updated.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type MailboxUpdated struct { 9 | updateBase 10 | 11 | *updateWaiter 12 | 13 | MailboxID MailboxID 14 | MailboxName []string 15 | } 16 | 17 | func NewMailboxUpdated(mailboxID MailboxID, mailboxName []string) *MailboxUpdated { 18 | return &MailboxUpdated{ 19 | updateWaiter: newUpdateWaiter(), 20 | MailboxID: mailboxID, 21 | MailboxName: mailboxName, 22 | } 23 | } 24 | 25 | func (u *MailboxUpdated) String() string { 26 | return fmt.Sprintf( 27 | "MailboxUpdated: MailboxID = %v, MailboxName = %v", 28 | u.MailboxID.ShortID(), 29 | ShortID(strings.Join(u.MailboxName, "/")), 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /internal/session/handle_check.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/internal/state" 9 | "github.com/ProtonMail/gluon/profiling" 10 | ) 11 | 12 | func (s *Session) handleCheck(ctx context.Context, tag string, _ *command.Check, mailbox *state.Mailbox, ch chan response.Response) (response.Response, error) { 13 | profiling.Start(ctx, profiling.CmdTypeCheck) 14 | defer profiling.Stop(ctx, profiling.CmdTypeCheck) 15 | 16 | if err := flush(ctx, mailbox, true, ch); err != nil { 17 | return nil, err 18 | } 19 | 20 | return response.Ok(tag).WithMessage("CHECK"), nil 21 | } 22 | -------------------------------------------------------------------------------- /imap/command/copy_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_CopyCommand(t *testing.T) { 12 | input := toIMAPLine(`tag COPY 1:* INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Copy{ 17 | Mailbox: "INBOX", 18 | SeqSet: []SeqRange{{Begin: 1, End: SeqNumValueAsterisk}}, 19 | }} 20 | 21 | cmd, err := p.Parse() 22 | require.NoError(t, err) 23 | require.Equal(t, expected, cmd) 24 | require.Equal(t, "copy", p.LastParsedCommand()) 25 | require.Equal(t, "tag", p.LastParsedTag()) 26 | } 27 | -------------------------------------------------------------------------------- /imap/command/move_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_MoveCommand(t *testing.T) { 12 | input := toIMAPLine(`tag MOVE 1:* INBOX`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "tag", Payload: &Move{ 17 | Mailbox: "INBOX", 18 | SeqSet: []SeqRange{{Begin: 1, End: SeqNumValueAsterisk}}, 19 | }} 20 | 21 | cmd, err := p.Parse() 22 | require.NoError(t, err) 23 | require.Equal(t, expected, cmd) 24 | require.Equal(t, "move", p.LastParsedCommand()) 25 | require.Equal(t, "tag", p.LastParsedTag()) 26 | } 27 | -------------------------------------------------------------------------------- /internal/session/handle_unselect.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/internal/state" 9 | "github.com/ProtonMail/gluon/profiling" 10 | ) 11 | 12 | func (s *Session) handleUnselect(ctx context.Context, tag string, _ *command.Unselect, mailbox *state.Mailbox, _ chan response.Response) (response.Response, error) { 13 | profiling.Start(ctx, profiling.CmdTypeUnselect) 14 | defer profiling.Stop(ctx, profiling.CmdTypeUnselect) 15 | 16 | if err := mailbox.Close(ctx); err != nil { 17 | return nil, err 18 | } 19 | 20 | return response.Ok(tag).WithMessage("UNSELECT"), nil 21 | } 22 | -------------------------------------------------------------------------------- /tests/subscribe_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSubscribe(t *testing.T) { 8 | runOneToOneTestWithAuth(t, defaultServerOptions(t, withDelimiter(".")), func(c *testConnection, _ *testSession) { 9 | c.C("A002 CREATE #news.comp.mail.mime") 10 | c.S("A002 OK CREATE") 11 | 12 | c.C("A003 SUBSCRIBE #this.name.does.not.exist") 13 | c.S("A003 NO no such mailbox") 14 | 15 | // Mailboxes are subscribed by default. 16 | c.C("A004 UNSUBSCRIBE #news.comp.mail.mime") 17 | c.OK("A004") 18 | 19 | c.C("A004 SUBSCRIBE #news.comp.mail.mime") 20 | c.OK("A004") 21 | 22 | c.C("A005 SUBSCRIBE #news.comp.mail.mime") 23 | c.S("A005 NO already subscribed to this mailbox") 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /internal/response/search.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "strconv" 5 | 6 | "golang.org/x/exp/slices" 7 | ) 8 | 9 | type search struct { 10 | seqs []uint32 11 | } 12 | 13 | func Search(seqs ...uint32) *search { 14 | slices.Sort(seqs) 15 | 16 | return &search{ 17 | seqs: seqs, 18 | } 19 | } 20 | 21 | func (r *search) Send(s Session) error { 22 | return s.WriteResponse(r.String()) 23 | } 24 | 25 | func (r *search) String() string { 26 | parts := []string{"*", "SEARCH"} 27 | 28 | if len(r.seqs) > 0 { 29 | var seqs []string 30 | 31 | for _, seq := range r.seqs { 32 | seqs = append(seqs, strconv.Itoa(int(seq))) 33 | } 34 | 35 | parts = append(parts, join(seqs)) 36 | } 37 | 38 | return join(parts) 39 | } 40 | -------------------------------------------------------------------------------- /internal/session/handle_sub.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/profiling" 9 | ) 10 | 11 | func (s *Session) handleSub(ctx context.Context, tag string, cmd *command.Subscribe, ch chan response.Response) error { 12 | profiling.Start(ctx, profiling.CmdTypeSubscribe) 13 | defer profiling.Stop(ctx, profiling.CmdTypeSubscribe) 14 | 15 | nameUTF8, err := s.decodeMailboxName(cmd.Mailbox) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | if err := s.state.Subscribe(ctx, nameUTF8); err != nil { 21 | return err 22 | } 23 | 24 | ch <- response.Ok(tag).WithMessage("SUB") 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/response/bad.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type bad struct { 4 | tag string 5 | err error 6 | } 7 | 8 | func Bad(withTag ...string) *bad { 9 | var tag string 10 | 11 | if len(withTag) > 0 { 12 | tag = withTag[0] 13 | } else { 14 | tag = "*" 15 | } 16 | 17 | return &bad{ 18 | tag: tag, 19 | } 20 | } 21 | 22 | func (r *bad) WithError(err error) *bad { 23 | r.err = err 24 | return r 25 | } 26 | 27 | func (r *bad) Send(s Session) error { 28 | return s.WriteResponse(r.String()) 29 | } 30 | 31 | func (r *bad) String() string { 32 | parts := []string{r.tag, "BAD"} 33 | 34 | if r.err != nil { 35 | parts = append(parts, r.err.Error()) 36 | } 37 | 38 | return join(parts) 39 | } 40 | 41 | func (r *bad) Error() string { 42 | return r.err.Error() 43 | } 44 | -------------------------------------------------------------------------------- /internal/session/handle_unsub.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/profiling" 9 | ) 10 | 11 | func (s *Session) handleUnsub(ctx context.Context, tag string, cmd *command.Unsubscribe, ch chan response.Response) error { 12 | profiling.Start(ctx, profiling.CmdTypeUnsubscribe) 13 | defer profiling.Stop(ctx, profiling.CmdTypeUnsubscribe) 14 | 15 | nameUTF8, err := s.decodeMailboxName(cmd.Mailbox) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | if err := s.state.Unsubscribe(ctx, nameUTF8); err != nil { 21 | return err 22 | } 23 | 24 | ch <- response.Ok(tag).WithMessage("UNSUBSCRIBE") 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/state/context.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import "context" 4 | 5 | type stateContextType struct{} 6 | 7 | var stateContextKey stateContextType 8 | 9 | // NewStateContext will annotate a context object with the state's assigned ID. This can later be used 10 | // to determine whether the current active call came from a state and which one. 11 | func NewStateContext(ctx context.Context, s *State) context.Context { 12 | if s == nil { 13 | return ctx 14 | } 15 | 16 | return context.WithValue(ctx, stateContextKey, s.StateID) 17 | } 18 | 19 | func GetStateIDFromContext(ctx context.Context) (StateID, bool) { 20 | v := ctx.Value(stateContextKey) 21 | if v == nil { 22 | return 0, false 23 | } 24 | 25 | stateID, ok := v.(StateID) 26 | 27 | return stateID, ok 28 | } 29 | -------------------------------------------------------------------------------- /internal/session/handle_logout.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/profiling" 9 | ) 10 | 11 | func (s *Session) handleLogout(ctx context.Context, tag string, _ *command.Logout) error { 12 | profiling.Start(ctx, profiling.CmdTypeLogout) 13 | defer profiling.Stop(ctx, profiling.CmdTypeLogout) 14 | 15 | s.userLock.Lock() 16 | defer s.userLock.Unlock() 17 | 18 | s.capsLock.Lock() 19 | defer s.capsLock.Unlock() 20 | 21 | if err := response.Bye().Send(s); err != nil { 22 | return err 23 | } 24 | 25 | if err := response.Ok(tag).WithMessage("LOGOUT").Send(s); err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /tests/parsed_message_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func BenchmarkParsedMessage(b *testing.B) { 12 | literal, err := os.ReadFile("testdata/multipart-mixed.eml") 13 | require.NoError(b, err) 14 | 15 | b.ResetTimer() 16 | 17 | for i := 0; i < b.N; i++ { 18 | _, err := imap.NewParsedMessage(literal) 19 | require.NoError(b, err) 20 | } 21 | } 22 | 23 | func BenchmarkParsedMessageLarge(b *testing.B) { 24 | literal, err := os.ReadFile("testdata/large_message.eml") 25 | require.NoError(b, err) 26 | 27 | b.ResetTimer() 28 | 29 | for i := 0; i < b.N; i++ { 30 | _, err := imap.NewParsedMessage(literal) 31 | require.NoError(b, err) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/session/handle_starttls.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | 7 | "github.com/ProtonMail/gluon/imap/command" 8 | "github.com/ProtonMail/gluon/internal/response" 9 | ) 10 | 11 | func (s *Session) handleStartTLS(tag string, _ *command.StartTLS) error { 12 | if s.tlsConfig == nil { 13 | return response.No(tag).WithError(ErrTLSUnavailable) 14 | } 15 | 16 | if err := response.Ok(tag).WithMessage("Begin TLS negotiation now").Send(s); err != nil { 17 | return err 18 | } 19 | 20 | conn := tls.Server(s.conn, s.tlsConfig) 21 | 22 | if err := conn.Handshake(); err != nil { 23 | return err 24 | } 25 | 26 | s.conn = conn 27 | 28 | s.inputCollector.Reset() 29 | s.inputCollector.SetSource(bufio.NewReader(s.conn)) 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /imap/seqset_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSeqSet(t *testing.T) { 10 | tests := []struct { 11 | have []SeqID 12 | want string 13 | }{ 14 | {have: []SeqID{}, want: ""}, 15 | {have: []SeqID{1}, want: "1"}, 16 | {have: []SeqID{1, 3}, want: "1,3"}, 17 | {have: []SeqID{1, 3, 5}, want: "1,3,5"}, 18 | {have: []SeqID{1, 2, 3, 5}, want: "1:3,5"}, 19 | {have: []SeqID{1, 2, 3, 5, 6}, want: "1:3,5:6"}, 20 | {have: []SeqID{1, 2, 3, 4, 5, 6}, want: "1:6"}, 21 | {have: []SeqID{1, 3, 4, 5, 6}, want: "1,3:6"}, 22 | } 23 | 24 | for _, tc := range tests { 25 | tc := tc 26 | 27 | t.Run(tc.want, func(t *testing.T) { 28 | assert.Equal(t, tc.want, NewSeqSet(tc.have).String()) 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/ids/ids.go: -------------------------------------------------------------------------------- 1 | package ids 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | ) 9 | 10 | const GluonRecoveryMailboxName = "Recovered Messages" 11 | const GluonRecoveryMailboxNameLowerCase = "recovered messages" 12 | const GluonInternalRecoveryMailboxRemoteID = imap.MailboxID("GLUON-INTERNAL-RECOVERY-MBOX") 13 | const gluonInternalRecoveredMessageRemoteIDPrefix = "GLUON-RECOVERED-MESSAGE" 14 | 15 | func NewRecoveredRemoteMessageID(internalID imap.InternalMessageID) imap.MessageID { 16 | return imap.MessageID(fmt.Sprintf("%v-%v", gluonInternalRecoveredMessageRemoteIDPrefix, internalID)) 17 | } 18 | 19 | func IsRecoveredRemoteMessageID(id imap.MessageID) bool { 20 | return strings.HasPrefix(string(id), gluonInternalRecoveredMessageRemoteIDPrefix) 21 | } 22 | -------------------------------------------------------------------------------- /tests/non_utf8_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/reporter/mock_reporter" 7 | "github.com/emersion/go-imap/client" 8 | "github.com/golang/mock/gomock" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSSLConnectionOverStartTLS(t *testing.T) { 13 | ctrl := gomock.NewController(t) 14 | reporter := mock_reporter.NewMockReporter(ctrl) 15 | 16 | defer ctrl.Finish() 17 | 18 | // Ensure the nothing is reported when connecting via TLS connection if we are not running with TLS 19 | runOneToOneTestClientWithAuth(t, defaultServerOptions(t, withReporter(reporter)), func(_ *client.Client, session *testSession) { 20 | _, err := client.DialTLS(session.listener.Addr().String(), nil) 21 | require.Error(t, err) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /imap/command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | 7 | "github.com/ProtonMail/gluon/internal/hash" 8 | ) 9 | 10 | type Payload interface { 11 | String() string 12 | 13 | // SanitizedString should return the command payload with all the sensitive information stripped out. 14 | SanitizedString() string 15 | } 16 | 17 | func sanitizeString(s string) string { 18 | return base64.StdEncoding.EncodeToString(hash.SHA256([]byte(s))) 19 | } 20 | 21 | type Command struct { 22 | Tag string 23 | Payload Payload 24 | } 25 | 26 | func (c Command) String() string { 27 | return fmt.Sprintf("%v %v", c.Tag, c.Payload.String()) 28 | } 29 | 30 | func (c Command) SanitizedString() string { 31 | return fmt.Sprintf("%v %v", c.Tag, c.Payload.SanitizedString()) 32 | } 33 | -------------------------------------------------------------------------------- /internal/response/bye.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type bye struct { 4 | msg string 5 | } 6 | 7 | func Bye() *bye { 8 | return &bye{} 9 | } 10 | 11 | func (r *bye) WithMessage(msg string) *bye { 12 | r.msg = msg 13 | return r 14 | } 15 | 16 | func (r *bye) Send(s Session) error { 17 | return s.WriteResponse(r.String()) 18 | } 19 | 20 | func (r *bye) String() string { 21 | parts := []string{"*", "BYE"} 22 | 23 | if r.msg != "" { 24 | parts = append(parts, r.msg) 25 | } 26 | 27 | return join(parts) 28 | } 29 | 30 | func (r *bye) WithMailboxDeleted() *bye { 31 | r.msg = "Mailbox was deleted, have to disconnect." 32 | 33 | return r 34 | } 35 | 36 | func (r *bye) WithInconsistentState() *bye { 37 | r.msg = "IMAP session state is inconsistent, please re-login." 38 | 39 | return r 40 | } 41 | -------------------------------------------------------------------------------- /internal/response/status.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type status struct { 9 | name string 10 | items []Item 11 | } 12 | 13 | func Status() *status { 14 | return &status{} 15 | } 16 | 17 | func (r *status) WithMailbox(name string) *status { 18 | r.name = name 19 | return r 20 | } 21 | 22 | func (r *status) WithItems(item ...Item) *status { 23 | r.items = append(r.items, item...) 24 | return r 25 | } 26 | 27 | func (r *status) Send(s Session) error { 28 | return s.WriteResponse(r.String()) 29 | } 30 | 31 | func (r *status) String() string { 32 | var items []string 33 | 34 | for _, item := range r.items { 35 | items = append(items, item.String()) 36 | } 37 | 38 | return fmt.Sprintf(`* STATUS %v (%v)`, strconv.Quote(r.name), join(items)) 39 | } 40 | -------------------------------------------------------------------------------- /imap/testdata/rfc822.eml: -------------------------------------------------------------------------------- 1 | Content-Type: multipart/mixed; 2 | boundary=b55536263d91d87d90bcd1f3249becaa7fc9105ddfbd96d7c058e1589725 3 | Date: Thu, 21 Nov 2013 16:33:01 +0100 4 | From: "Some Body" 5 | Subject: Empty header again 6 | To: "Some Body Else" 7 | Mime-Version: 1.0 8 | 9 | --b55536263d91d87d90bcd1f3249becaa7fc9105ddfbd96d7c058e1589725 10 | Content-Transfer-Encoding: quoted-printable 11 | Content-Type: text/html; charset=utf-8 12 | 13 |

Test

14 | --b55536263d91d87d90bcd1f3249becaa7fc9105ddfbd96d7c058e1589725 15 | Content-Disposition: attachment; filename=filename.mail 16 | Content-Type: message/rfc822; name=filename.mail 17 | 18 | # The command is: 19 | rm -rf / 20 | --b55536263d91d87d90bcd1f3249becaa7fc9105ddfbd96d7c058e1589725-- 21 | -------------------------------------------------------------------------------- /internal/session/context.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type startTimeType struct{} 10 | 11 | var startTimeKey startTimeType 12 | 13 | func withStartTime(parent context.Context, startTime time.Time) context.Context { 14 | return context.WithValue(parent, startTimeKey, startTime) 15 | } 16 | 17 | func startTimeFromContext(ctx context.Context) (time.Time, bool) { 18 | startTime, ok := ctx.Value(startTimeKey).(time.Time) 19 | return startTime, ok 20 | } 21 | 22 | func okMessage(ctx context.Context) string { 23 | if startTime, ok := startTimeFromContext(ctx); ok { 24 | elapsed := time.Since(startTime) 25 | microSec := elapsed.Microseconds() 26 | 27 | return fmt.Sprintf("command completed in %v microsec.", microSec) 28 | } 29 | 30 | return "" 31 | } 32 | -------------------------------------------------------------------------------- /internal/response/capability.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "golang.org/x/exp/slices" 8 | ) 9 | 10 | type capability struct { 11 | caps []imap.Capability 12 | } 13 | 14 | func Capability() *capability { 15 | return &capability{} 16 | } 17 | 18 | func (r *capability) WithCapabilities(caps ...imap.Capability) *capability { 19 | r.caps = append(r.caps, caps...) 20 | return r 21 | } 22 | 23 | func (r *capability) Send(s Session) error { 24 | return s.WriteResponse(r.String()) 25 | } 26 | 27 | func (r *capability) String() string { 28 | var caps []string 29 | 30 | for _, capability := range r.caps { 31 | caps = append(caps, string(capability)) 32 | } 33 | 34 | slices.Sort(caps) 35 | 36 | return fmt.Sprintf("* CAPABILITY %v", join(caps)) 37 | } 38 | -------------------------------------------------------------------------------- /rfc822/writer.go: -------------------------------------------------------------------------------- 1 | package rfc822 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | type MultipartWriter struct { 10 | w io.Writer 11 | boundary string 12 | } 13 | 14 | func NewMultipartWriter(w io.Writer, boundary string) *MultipartWriter { 15 | return &MultipartWriter{w: w, boundary: boundary} 16 | } 17 | 18 | func (w *MultipartWriter) AddPart(fn func(io.Writer) error) error { 19 | buf := new(bytes.Buffer) 20 | 21 | if err := fn(buf); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := fmt.Fprintf(w.w, "--%v\r\n%v\r\n", w.boundary, buf.String()); err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func (w *MultipartWriter) Done() error { 33 | if _, err := fmt.Fprintf(w.w, "--%v--\r\n", w.boundary); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/session/handle_noop.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/internal/state" 9 | "github.com/ProtonMail/gluon/profiling" 10 | ) 11 | 12 | func (s *Session) handleNoop(ctx context.Context, tag string, _ *command.Noop, ch chan response.Response) error { 13 | profiling.Start(ctx, profiling.CmdTypeNoop) 14 | defer profiling.Stop(ctx, profiling.CmdTypeNoop) 15 | 16 | if (s.state != nil) && s.state.IsSelected() { 17 | if err := s.state.Selected(ctx, func(mailbox *state.Mailbox) error { 18 | return flush(ctx, mailbox, true, ch) 19 | }); err != nil { 20 | return err 21 | } 22 | } 23 | 24 | ch <- response.Ok(tag).WithMessage(okMessage(ctx)) 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /observability/utils.go: -------------------------------------------------------------------------------- 1 | package observability 2 | 3 | import "context" 4 | 5 | func AddImapMetric(ctx context.Context, metric ...map[string]interface{}) { 6 | sender, ok := getObservabilitySenderFromContext(ctx) 7 | if !ok { 8 | return 9 | } 10 | 11 | sender.AddDistinctMetrics(imapErrorMetricType, metric...) 12 | } 13 | 14 | func AddMessageRelatedMetric(ctx context.Context, metric ...map[string]interface{}) { 15 | sender, ok := getObservabilitySenderFromContext(ctx) 16 | if !ok { 17 | return 18 | } 19 | 20 | sender.AddDistinctMetrics(messageErrorMetricType, metric...) 21 | } 22 | 23 | func AddOtherMetric(ctx context.Context, metric ...map[string]interface{}) { 24 | sender, ok := getObservabilitySenderFromContext(ctx) 25 | if !ok { 26 | return 27 | } 28 | 29 | sender.AddDistinctMetrics(otherErrorMetricType, metric...) 30 | } 31 | -------------------------------------------------------------------------------- /internal/session/handle_rename.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/profiling" 9 | ) 10 | 11 | func (s *Session) handleRename(ctx context.Context, tag string, cmd *command.Rename, ch chan response.Response) error { 12 | profiling.Start(ctx, profiling.CmdTypeRename) 13 | defer profiling.Stop(ctx, profiling.CmdTypeRename) 14 | 15 | oldNameUTF8, err := s.decodeMailboxName(cmd.From) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | newNameUTF8, err := s.decodeMailboxName(cmd.To) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if err := s.state.Rename(ctx, oldNameUTF8, newNameUTF8); err != nil { 26 | return err 27 | } 28 | 29 | ch <- response.Ok(tag).WithMessage("RENAME") 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /imap/command/create.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Create struct { 10 | Mailbox string 11 | } 12 | 13 | func (l Create) String() string { 14 | return fmt.Sprintf("CREATE '%v'", l.Mailbox) 15 | } 16 | 17 | func (l Create) SanitizedString() string { 18 | return fmt.Sprintf("CREATE '%v'", sanitizeString(l.Mailbox)) 19 | } 20 | 21 | type CreateCommandParser struct{} 22 | 23 | func (CreateCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 24 | // create = "CREATE" SP mailbox 25 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 26 | return nil, err 27 | } 28 | 29 | mailbox, err := ParseMailbox(p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Create{ 35 | Mailbox: mailbox.Value, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /imap/command/delete.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Delete struct { 10 | Mailbox string 11 | } 12 | 13 | func (l Delete) String() string { 14 | return fmt.Sprintf("DELETE '%v'", l.Mailbox) 15 | } 16 | 17 | func (l Delete) SanitizedString() string { 18 | return fmt.Sprintf("DELETE '%v'", sanitizeString(l.Mailbox)) 19 | } 20 | 21 | type DeleteCommandParser struct{} 22 | 23 | func (DeleteCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 24 | // delete = "DELETE" SP mailbox 25 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 26 | return nil, err 27 | } 28 | 29 | mailbox, err := ParseMailbox(p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Delete{ 35 | Mailbox: mailbox.Value, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /imap/command/select.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Select struct { 10 | Mailbox string 11 | } 12 | 13 | func (l Select) String() string { 14 | return fmt.Sprintf("SELECT '%v'", l.Mailbox) 15 | } 16 | 17 | func (l Select) SanitizedString() string { 18 | return fmt.Sprintf("SELECT '%v'", sanitizeString(l.Mailbox)) 19 | } 20 | 21 | type SelectCommandParser struct{} 22 | 23 | func (SelectCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 24 | // select = "SELECT" SP mailbox 25 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 26 | return nil, err 27 | } 28 | 29 | mailbox, err := ParseMailbox(p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Select{ 35 | Mailbox: mailbox.Value, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /imap/command/examine.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Examine struct { 10 | Mailbox string 11 | } 12 | 13 | func (l Examine) String() string { 14 | return fmt.Sprintf("EXAMINE '%v'", l.Mailbox) 15 | } 16 | 17 | func (l Examine) SanitizedString() string { 18 | return fmt.Sprintf("EXAMINE '%v'", sanitizeString(l.Mailbox)) 19 | } 20 | 21 | type ExamineCommandParser struct{} 22 | 23 | func (ExamineCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 24 | // examine = "EXAMINE" SP mailbox 25 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 26 | return nil, err 27 | } 28 | 29 | mailbox, err := ParseMailbox(p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Examine{ 35 | Mailbox: mailbox.Value, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /imap/command/done_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_DoneCommand(t *testing.T) { 12 | input := toIMAPLine(`DONE`) 13 | s := rfcparser.NewScanner(bytes.NewReader(input)) 14 | p := NewParser(s) 15 | 16 | expected := Command{Tag: "", Payload: &Done{}} 17 | 18 | cmd, err := p.Parse() 19 | require.NoError(t, err) 20 | require.Equal(t, expected, cmd) 21 | require.Equal(t, "done", p.LastParsedCommand()) 22 | require.Empty(t, p.LastParsedTag()) 23 | } 24 | 25 | func TestParser_DoneCommandAfterTagIsError(t *testing.T) { 26 | input := toIMAPLine(`tag DONE`) 27 | s := rfcparser.NewScanner(bytes.NewReader(input)) 28 | p := NewParser(s) 29 | _, err := p.Parse() 30 | require.Error(t, err) 31 | require.Equal(t, "tag", p.LastParsedTag()) 32 | } 33 | -------------------------------------------------------------------------------- /store/fallback_v0/compressor_gzip.go: -------------------------------------------------------------------------------- 1 | package fallback_v0 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | ) 7 | 8 | type GZipCompressor struct{} 9 | 10 | func (GZipCompressor) Compress(dec []byte) ([]byte, error) { 11 | buf := new(bytes.Buffer) 12 | 13 | zw := gzip.NewWriter(buf) 14 | 15 | if _, err := zw.Write(dec); err != nil { 16 | return nil, err 17 | } 18 | 19 | if err := zw.Close(); err != nil { 20 | return nil, err 21 | } 22 | 23 | return buf.Bytes(), nil 24 | } 25 | 26 | func (GZipCompressor) Decompress(cmp []byte) ([]byte, error) { 27 | zr, err := gzip.NewReader(bytes.NewReader(cmp)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | buf := new(bytes.Buffer) 33 | 34 | if _, err := buf.ReadFrom(zr); err != nil { 35 | return nil, err 36 | } 37 | 38 | if err := zr.Close(); err != nil { 39 | return nil, err 40 | } 41 | 42 | return buf.Bytes(), nil 43 | } 44 | -------------------------------------------------------------------------------- /store/fallback_v0/compressor_zlib.go: -------------------------------------------------------------------------------- 1 | package fallback_v0 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | ) 7 | 8 | type ZLibCompressor struct{} 9 | 10 | func (ZLibCompressor) Compress(dec []byte) ([]byte, error) { 11 | buf := new(bytes.Buffer) 12 | 13 | zw := zlib.NewWriter(buf) 14 | 15 | if _, err := zw.Write(dec); err != nil { 16 | return nil, err 17 | } 18 | 19 | if err := zw.Close(); err != nil { 20 | return nil, err 21 | } 22 | 23 | return buf.Bytes(), nil 24 | } 25 | 26 | func (ZLibCompressor) Decompress(cmp []byte) ([]byte, error) { 27 | zr, err := zlib.NewReader(bytes.NewReader(cmp)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | buf := new(bytes.Buffer) 33 | 34 | if _, err := buf.ReadFrom(zr); err != nil { 35 | return nil, err 36 | } 37 | 38 | if err := zr.Close(); err != nil { 39 | return nil, err 40 | } 41 | 42 | return buf.Bytes(), nil 43 | } 44 | -------------------------------------------------------------------------------- /imap/command/subscribe.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Subscribe struct { 10 | Mailbox string 11 | } 12 | 13 | func (l Subscribe) String() string { 14 | return fmt.Sprintf("SUBSCRIBE '%v'", l.Mailbox) 15 | } 16 | 17 | func (l Subscribe) SanitizedString() string { 18 | return fmt.Sprintf("SUBSCRIBE '%v'", sanitizeString(l.Mailbox)) 19 | } 20 | 21 | type SubscribeCommandParser struct{} 22 | 23 | func (SubscribeCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 24 | // subscribe = "SUBSCRIBE" SP mailbox 25 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 26 | return nil, err 27 | } 28 | 29 | mailbox, err := ParseMailbox(p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Subscribe{ 35 | Mailbox: mailbox.Value, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /imap/command/seq_set_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParseSeqSet(t *testing.T) { 12 | input := []byte(`1:*,*,20,40:30`) 13 | expected := []SeqRange{ 14 | { 15 | Begin: SeqNum(1), 16 | End: SeqNumValueAsterisk, 17 | }, 18 | { 19 | Begin: SeqNumValueAsterisk, 20 | End: SeqNumValueAsterisk, 21 | }, 22 | { 23 | Begin: SeqNum(20), 24 | End: SeqNum(20), 25 | }, 26 | { 27 | Begin: SeqNum(40), 28 | End: SeqNum(30), 29 | }, 30 | } 31 | 32 | p := rfcparser.NewParser(rfcparser.NewScanner(bytes.NewReader(input))) 33 | // Advance at least once to prepare first token. 34 | err := p.Advance() 35 | require.NoError(t, err) 36 | 37 | dt, err := ParseSeqSet(p) 38 | require.NoError(t, err) 39 | require.Equal(t, expected, dt) 40 | } 41 | -------------------------------------------------------------------------------- /imap/command/unsubscribe.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Unsubscribe struct { 10 | Mailbox string 11 | } 12 | 13 | func (l Unsubscribe) String() string { 14 | return fmt.Sprintf("UNSUBSCRIBE '%v'", l.Mailbox) 15 | } 16 | 17 | func (l Unsubscribe) SanitizedString() string { 18 | return fmt.Sprintf("UNSUBSCRIBE '%v'", sanitizeString(l.Mailbox)) 19 | } 20 | 21 | type UnsubscribeCommandParser struct{} 22 | 23 | func (UnsubscribeCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 24 | // unsubscribe = "UNSUBSCRIBE" SP mailbox 25 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 26 | return nil, err 27 | } 28 | 29 | mailbox, err := ParseMailbox(p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Unsubscribe{ 35 | Mailbox: mailbox.Value, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /imap/update_message_labels_updated.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/bradenaw/juniper/xslices" 7 | ) 8 | 9 | type MessageMailboxesUpdated struct { 10 | updateBase 11 | 12 | *updateWaiter 13 | 14 | MessageID MessageID 15 | MailboxIDs []MailboxID 16 | Flags FlagSet 17 | } 18 | 19 | func NewMessageMailboxesUpdated(messageID MessageID, mailboxIDs []MailboxID, flags FlagSet) *MessageMailboxesUpdated { 20 | return &MessageMailboxesUpdated{ 21 | updateWaiter: newUpdateWaiter(), 22 | MessageID: messageID, 23 | MailboxIDs: mailboxIDs, 24 | Flags: flags, 25 | } 26 | } 27 | 28 | func (u *MessageMailboxesUpdated) String() string { 29 | return fmt.Sprintf( 30 | "MessageMailboxesUpdated: MessageID = %v, MailboxIDs = %v, Flags = %v", 31 | u.MessageID.ShortID(), 32 | xslices.Map(u.MailboxIDs, func(id MailboxID) string { return id.ShortID() }), 33 | u.Flags.ToSlice(), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/flags/general.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import "flag" 4 | 5 | var ( 6 | BenchPath = flag.String("path", "", "Filepath where to write the database data. If not set a temp folder will be used.") 7 | Verbose = flag.Bool("verbose", false, "Enable verbose logging.") 8 | JsonReporter = flag.String("json-reporter", "", "If specified, will generate a json report with the given filename.") 9 | BenchmarkRuns = flag.Uint("bench-runs", 1, "Number of runs per benchmark.") 10 | Connector = flag.String("connector", "dummy", "Key of the connector implementation registered with ConnectorFactory.") 11 | UserName = flag.String("user-name", "user", "Username for the connector user, defaults to 'user'.") 12 | UserPassword = flag.String("user-pwd", "password", "Password for the connector user, defaults to 'password'.") 13 | SkipClean = flag.Bool("skip-clean", false, "Do not cleanup benchmark data directory.") 14 | ) 15 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/flags/imap_benchmarks.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import "flag" 4 | 5 | var ( 6 | IMAPRemoteServer = flag.String("imap-remote-server", "", "IP address and port of the remote IMAP server to run against. E.g. 127.0.0.1:1143.") 7 | IMAPMessageCount = flag.Uint("imap-msg-count", 1000, "Number of messages to add to the mailbox before each benchmark") 8 | IMAPRandomSeqSetIntervals = flag.Bool("imap-random-seqset-intervals", false, "When set, generate random sequence intervals rather than single numbers.") 9 | IMAPUIDMode = flag.Bool("imap-uid-mode", false, "When set, will run benchmarks in UID mode if available.") 10 | IMAPParallelClients = flag.Uint("imap-parallel-clients", 1, "Set the number of clients to be run in parallel during the benchmark.") 11 | IMAPMailboxMessageDir = flag.String("imap-mbox-file-dir", "", "Folder path to load *.eml messages instead of builtin selection.") 12 | ) 13 | -------------------------------------------------------------------------------- /internal/response/list.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | ) 9 | 10 | type list struct { 11 | name, del string 12 | att imap.FlagSet 13 | } 14 | 15 | func List() *list { 16 | return &list{att: imap.NewFlagSet()} 17 | } 18 | 19 | func (r *list) WithName(name string) *list { 20 | r.name = name 21 | return r 22 | } 23 | 24 | func (r *list) WithDelimiter(del string) *list { 25 | r.del = del 26 | return r 27 | } 28 | 29 | func (r *list) WithAttributes(att imap.FlagSet) *list { 30 | r.att.AddFlagSetToSelf(att) 31 | return r 32 | } 33 | 34 | func (r *list) Send(s Session) error { 35 | return s.WriteResponse(r.String()) 36 | } 37 | 38 | func (r *list) String() string { 39 | del := "NIL" 40 | 41 | if r.del != "" { 42 | del = strconv.Quote(r.del) 43 | } 44 | 45 | return fmt.Sprintf(`* LIST (%v) %v %v`, join(r.att.ToSlice()), del, strconv.Quote(r.name)) 46 | } 47 | -------------------------------------------------------------------------------- /internal/response/lsub.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | ) 9 | 10 | type lsub struct { 11 | name, del string 12 | att imap.FlagSet 13 | } 14 | 15 | func Lsub() *lsub { 16 | return &lsub{att: imap.NewFlagSet()} 17 | } 18 | 19 | func (r *lsub) WithName(name string) *lsub { 20 | r.name = name 21 | return r 22 | } 23 | 24 | func (r *lsub) WithDelimiter(del string) *lsub { 25 | r.del = del 26 | return r 27 | } 28 | 29 | func (r *lsub) WithAttributes(att imap.FlagSet) *lsub { 30 | r.att.AddFlagSetToSelf(att) 31 | return r 32 | } 33 | 34 | func (r *lsub) Send(s Session) error { 35 | return s.WriteResponse(r.String()) 36 | } 37 | 38 | func (r *lsub) String() string { 39 | del := "NIL" 40 | 41 | if r.del != "" { 42 | del = strconv.Quote(r.del) 43 | } 44 | 45 | return fmt.Sprintf(`* LSUB (%v) %v %v`, join(r.att.ToSlice()), del, strconv.Quote(r.name)) 46 | } 47 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package gluon 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "testing" 7 | 8 | "github.com/ProtonMail/gluon/events" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func _TestServer(t *testing.T) { 13 | server, err := New() 14 | require.NoError(t, err) 15 | 16 | ctx, cancel := context.WithCancel(context.Background()) 17 | defer cancel() 18 | 19 | // Get an event channel. 20 | eventCh := server.AddWatcher(events.ListenerAdded{}, events.ListenerRemoved{}) 21 | 22 | // Create a listener. 23 | l, err := net.Listen("tcp", net.JoinHostPort("localhost", "0")) 24 | require.NoError(t, err) 25 | 26 | // The first listen is successful. 27 | require.NoError(t, server.Serve(ctx, l)) 28 | require.Equal(t, events.ListenerAdded{Addr: l.Addr()}, <-eventCh) 29 | 30 | // The server closes successfully. 31 | require.NoError(t, server.Close(ctx)) 32 | require.Equal(t, events.ListenerRemoved{Addr: l.Addr()}, <-eventCh) 33 | } 34 | -------------------------------------------------------------------------------- /internal/session/handle_capability.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/ProtonMail/gluon/imap/command" 8 | "github.com/ProtonMail/gluon/internal/response" 9 | ) 10 | 11 | func (s *Session) getCaps() []imap.Capability { 12 | s.userLock.Lock() 13 | defer s.userLock.Unlock() 14 | 15 | if s.state != nil { 16 | return s.caps 17 | } 18 | 19 | var caps []imap.Capability 20 | for _, c := range s.caps { 21 | if imap.IsCapabilityAvailableBeforeAuth(c) { 22 | caps = append(caps, c) 23 | } 24 | } 25 | 26 | return caps 27 | } 28 | 29 | func (s *Session) handleCapability(_ context.Context, tag string, _ *command.Capability, ch chan response.Response) error { 30 | s.capsLock.Lock() 31 | defer s.capsLock.Unlock() 32 | 33 | ch <- response.Capability().WithCapabilities(s.getCaps()...) 34 | 35 | ch <- response.Ok(tag).WithMessage("CAPABILITY") 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /imap/envelope_test.go: -------------------------------------------------------------------------------- 1 | package imap_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/ProtonMail/gluon/rfc822" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestEnvelope(t *testing.T) { 14 | b, err := os.ReadFile("testdata/envelope.eml") 15 | require.NoError(t, err) 16 | 17 | root := rfc822.Parse(b) 18 | 19 | header, err := root.ParseHeader() 20 | require.NoError(t, err) 21 | 22 | envelope, err := imap.Envelope(header) 23 | require.NoError(t, err) 24 | 25 | assert.Equal(t, "(\"Sat, 03 Apr 2021 15:13:53 +0000\" \"this is currently a draft\" ((NIL NIL \"somebody\" \"pm.me\")) ((NIL NIL \"somebody\" \"pm.me\")) ((NIL NIL \"somebody\" \"pm.me\")) ((\"Somebody\" NIL \"somebody\" \"pm.me\")) NIL NIL NIL \"\")", envelope) 26 | } 27 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/timing/timing.go: -------------------------------------------------------------------------------- 1 | package timing 2 | 3 | import "time" 4 | 5 | // Timer tracks the duration between invocations to Start and Stop. 6 | type Timer struct { 7 | start time.Time 8 | end time.Time 9 | } 10 | 11 | func (s *Timer) Start() { 12 | s.start = time.Now() 13 | } 14 | 15 | func (s *Timer) Stop() { 16 | s.end = time.Now() 17 | } 18 | 19 | func (s *Timer) Elapsed() time.Duration { 20 | return s.end.Sub(s.start) 21 | } 22 | 23 | type Collector struct { 24 | durations []time.Duration 25 | timer Timer 26 | } 27 | 28 | func NewDurationCollector(capacity int) *Collector { 29 | return &Collector{ 30 | durations: make([]time.Duration, 0, capacity), 31 | } 32 | } 33 | 34 | func (d *Collector) Start() { 35 | d.timer.Start() 36 | } 37 | 38 | func (d *Collector) Stop() { 39 | d.timer.Stop() 40 | d.durations = append(d.durations, d.timer.Elapsed()) 41 | } 42 | 43 | func (d *Collector) Durations() []time.Duration { 44 | return d.durations 45 | } 46 | -------------------------------------------------------------------------------- /imap/command/nstring_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParseNStringString(t *testing.T) { 12 | input := []byte(`"foo"`) 13 | 14 | p := rfcparser.NewParser(rfcparser.NewScanner(bytes.NewReader(input))) 15 | // Advance at least once to prepare first token. 16 | err := p.Advance() 17 | require.NoError(t, err) 18 | 19 | v, isNil, err := ParseNString(p) 20 | require.NoError(t, err) 21 | require.Equal(t, "foo", v.Value) 22 | require.False(t, isNil) 23 | } 24 | 25 | func TestParseNStringNIL(t *testing.T) { 26 | input := []byte(`NIL`) 27 | 28 | p := rfcparser.NewParser(rfcparser.NewScanner(bytes.NewReader(input))) 29 | // Advance at least once to prepare first token. 30 | err := p.Advance() 31 | require.NoError(t, err) 32 | 33 | _, isNil, err := ParseNString(p) 34 | require.NoError(t, err) 35 | require.True(t, isNil) 36 | } 37 | -------------------------------------------------------------------------------- /logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | "runtime/pprof" 8 | ) 9 | 10 | type Labels map[string]any 11 | 12 | const ( 13 | // FnKey refers to the function name. 14 | FnKey = "fn" 15 | 16 | // FileKey refers to the file name. 17 | FileKey = "file" 18 | 19 | // LineKey refers to the line number. 20 | LineKey = "line" 21 | ) 22 | 23 | func DoAnnotated(ctx context.Context, fn func(context.Context), labelMap ...Labels) { 24 | pprofDo(ctx, toLabelSet(labelMap...), fn) 25 | } 26 | 27 | func toLabelSet(labelMap ...Labels) pprof.LabelSet { 28 | pc, file, line, ok := runtime.Caller(2) 29 | if !ok { 30 | panic("failed to get caller's stack frame") 31 | } 32 | 33 | var labels []string 34 | 35 | for _, labelMap := range append(labelMap, getDefaultLabels(pc, file, line)) { 36 | for key, val := range labelMap { 37 | labels = append(labels, key, fmt.Sprintf("%v", val)) 38 | } 39 | } 40 | 41 | return pprof.Labels(labels...) 42 | } 43 | -------------------------------------------------------------------------------- /imap/update_mailbox_deleted.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type MailboxDeleted struct { 8 | updateBase 9 | 10 | *updateWaiter 11 | 12 | MailboxID MailboxID 13 | } 14 | 15 | func NewMailboxDeleted(mailboxID MailboxID) *MailboxDeleted { 16 | return &MailboxDeleted{ 17 | updateWaiter: newUpdateWaiter(), 18 | MailboxID: mailboxID, 19 | } 20 | } 21 | 22 | func (u *MailboxDeleted) String() string { 23 | return fmt.Sprintf("MailboxDeleted: MailboxID = %v", u.MailboxID.ShortID()) 24 | } 25 | 26 | type MailboxDeletedSilent struct { 27 | updateBase 28 | 29 | *updateWaiter 30 | 31 | MailboxID MailboxID 32 | } 33 | 34 | func NewMailboxDeletedSilent(mailboxID MailboxID) *MailboxDeletedSilent { 35 | return &MailboxDeletedSilent{ 36 | updateWaiter: newUpdateWaiter(), 37 | MailboxID: mailboxID, 38 | } 39 | } 40 | 41 | func (u *MailboxDeletedSilent) String() string { 42 | return fmt.Sprintf("MailboxDeletedSilent: MailboxID = %v", u.MailboxID.ShortID()) 43 | } 44 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // NewRandomUserID return a new random user ID. For debugging purposes, the ID starts with the 'user-' prefix. 10 | func NewRandomUserID() string { 11 | return "usr-" + uuid.NewString() 12 | } 13 | 14 | // NewRandomMailboxID return a new random mailbox ID. For debugging purposes, the ID starts with the 'lbl-' prefix. 15 | func NewRandomMailboxID() string { 16 | return "lbl-" + uuid.NewString() 17 | } 18 | 19 | // NewRandomMessageID return a new random message ID. For debugging purposes, the ID starts with the 'message-' prefix. 20 | func NewRandomMessageID() string { 21 | return "msg-" + uuid.NewString() 22 | } 23 | 24 | // ErrCause returns the cause of the error, the inner-most error in the wrapped chain. 25 | func ErrCause(err error) error { 26 | cause := err 27 | 28 | for errors.Unwrap(cause) != nil { 29 | cause = errors.Unwrap(cause) 30 | } 31 | 32 | return cause 33 | } 34 | -------------------------------------------------------------------------------- /internal/response/item_body_literal.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type itemBodyLiteral struct { 6 | section string 7 | literal []byte 8 | partial int 9 | } 10 | 11 | func ItemBodyLiteral(section string, literal []byte) *itemBodyLiteral { 12 | return &itemBodyLiteral{ 13 | section: section, 14 | literal: literal, 15 | partial: -1, 16 | } 17 | } 18 | 19 | func (r *itemBodyLiteral) WithPartial(begin, count int) *itemBodyLiteral { 20 | r.partial = begin 21 | 22 | if literalLen := len(r.literal); begin >= literalLen { 23 | r.literal = nil 24 | } else if begin+count > literalLen { 25 | r.literal = r.literal[begin:] 26 | } else { 27 | r.literal = r.literal[begin : begin+count] 28 | } 29 | 30 | return r 31 | } 32 | 33 | func (r *itemBodyLiteral) String() string { 34 | var partial string 35 | 36 | if r.partial >= 0 { 37 | partial = fmt.Sprintf("<%v>", r.partial) 38 | } 39 | 40 | return fmt.Sprintf("BODY[%v]%v {%v}\r\n%s", r.section, partial, len(r.literal), r.literal) 41 | } 42 | -------------------------------------------------------------------------------- /internal/session/handle_close.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/contexts" 8 | "github.com/ProtonMail/gluon/internal/response" 9 | "github.com/ProtonMail/gluon/internal/state" 10 | "github.com/ProtonMail/gluon/profiling" 11 | ) 12 | 13 | func (s *Session) handleClose(ctx context.Context, tag string, _ *command.Close, mailbox *state.Mailbox, ch chan response.Response) (response.Response, error) { 14 | profiling.Start(ctx, profiling.CmdTypeClose) 15 | defer profiling.Stop(ctx, profiling.CmdTypeClose) 16 | 17 | ctx = contexts.AsClose(ctx) 18 | 19 | if !mailbox.ReadOnly() { 20 | if err := mailbox.Expunge(ctx, nil); err != nil { 21 | return nil, err 22 | } 23 | } 24 | 25 | if err := flush(ctx, mailbox, true, ch); err != nil { 26 | return nil, err 27 | } 28 | 29 | if err := mailbox.Close(ctx); err != nil { 30 | return nil, err 31 | } 32 | 33 | return response.Ok(tag).WithMessage("CLOSE"), nil 34 | } 35 | -------------------------------------------------------------------------------- /rfc822/writer_test.go: -------------------------------------------------------------------------------- 1 | package rfc822 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestMultipartWriter(t *testing.T) { 14 | b := new(bytes.Buffer) 15 | 16 | w := NewMultipartWriter(b, "boundary") 17 | 18 | require.NoError(t, w.AddPart(func(w io.Writer) error { 19 | if _, err := fmt.Fprintf(w, "1"); err != nil { 20 | return err 21 | } 22 | 23 | return nil 24 | })) 25 | 26 | require.NoError(t, w.AddPart(func(w io.Writer) error { 27 | if _, err := fmt.Fprintf(w, "2"); err != nil { 28 | return err 29 | } 30 | 31 | return nil 32 | })) 33 | 34 | require.NoError(t, w.AddPart(func(w io.Writer) error { 35 | if _, err := fmt.Fprintf(w, "3"); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | })) 41 | 42 | require.NoError(t, w.Done()) 43 | 44 | assert.Equal(t, "--boundary\r\n1\r\n--boundary\r\n2\r\n--boundary\r\n3\r\n--boundary--\r\n", b.String()) 45 | } 46 | -------------------------------------------------------------------------------- /rfc5322/atom_test.go: -------------------------------------------------------------------------------- 1 | package rfc5322 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestParseDotAtom(t *testing.T) { 10 | inputs := map[string]string{ 11 | "foobar.!#$%'*+-=?^~_{}`|/": "foobar.!#$%'*+-=?^~_{}`|/", 12 | " f.b ": "f.b", 13 | " \r\n f.b": "f.b", 14 | " \r\n f.b \r\n ": "f.b", 15 | } 16 | 17 | for i, e := range inputs { 18 | p := newTestRFCParser(i) 19 | v, err := parseDotAtom(p) 20 | require.NoError(t, err) 21 | require.Equal(t, e, v.Value) 22 | } 23 | } 24 | 25 | func TestParseAtom(t *testing.T) { 26 | inputs := map[string]string{ 27 | "foobar!#$%'*+-=?^~_{}`|/": "foobar!#$%'*+-=?^~_{}`|/", 28 | " fb ": "fb", 29 | " \r\n fb": "fb", 30 | " \r\n fb \r\n ": "fb", 31 | } 32 | 33 | for i, e := range inputs { 34 | p := newTestRFCParser(i) 35 | v, err := parseDotAtom(p) 36 | require.NoError(t, err) 37 | require.Equal(t, e, v.Value) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/state/user_interface.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/db" 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/ProtonMail/gluon/internal/utils" 9 | "github.com/ProtonMail/gluon/store" 10 | ) 11 | 12 | // UserInterface represents the expected behaviour for interacting with a remote user. 13 | // Sadly, due to Go's cyclic dependencies, this needs to be an interface. The implementation of this interface 14 | // is available in the backend package. 15 | type UserInterface interface { 16 | GetUserID() string 17 | 18 | GetDelimiter() string 19 | 20 | GetDB() db.Client 21 | 22 | GetRemote() Connector 23 | 24 | GetStore() *store.WriteControlledStore 25 | 26 | QueueOrApplyStateUpdate(ctx context.Context, tx db.Transaction, update ...Update) error 27 | 28 | ReleaseState(ctx context.Context, st *State) error 29 | 30 | GetRecoveryMailboxID() db.MailboxIDPair 31 | 32 | GenerateUIDValidity() (imap.UID, error) 33 | 34 | GetRecoveredMessageHashesMap() *utils.MessageHashesMap 35 | } 36 | -------------------------------------------------------------------------------- /internal/state/paths.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/bradenaw/juniper/xslices" 7 | "golang.org/x/exp/slices" 8 | ) 9 | 10 | // listSuperiors returns all names superior to the given name, if hierarchies are indicated with the given delimiter. 11 | func listSuperiors(name, delimiter string) []string { 12 | if delimiter == "" { 13 | return nil 14 | } 15 | 16 | split := strings.Split(name, delimiter) 17 | if len(split) == 0 { 18 | return nil 19 | } 20 | 21 | var inferiors []string 22 | 23 | for i := range split { 24 | if i == 0 { 25 | continue 26 | } 27 | 28 | inferiors = append(inferiors, strings.Join(split[0:i], delimiter)) 29 | } 30 | 31 | return inferiors 32 | } 33 | 34 | func listInferiors(parent, delimiter string, names []string) []string { 35 | inferiors := xslices.Filter(names, func(name string) bool { 36 | return slices.Contains(listSuperiors(name, delimiter), parent) 37 | }) 38 | 39 | slices.Sort(inferiors) 40 | 41 | xslices.Reverse(inferiors) 42 | 43 | return inferiors 44 | } 45 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/benchmark/benchmark.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/reporter" 7 | ) 8 | 9 | type Benchmark interface { 10 | // Name should return the name of the benchmark. It will also be used to match against cli args. 11 | Name() string 12 | 13 | // Setup sets up the benchmark state, this is not timed. 14 | Setup(ctx context.Context, benchmarkDir string) error 15 | 16 | // Run performs the actual benchmark, this is timed. 17 | Run(ctx context.Context) (*reporter.BenchmarkRun, error) 18 | 19 | // TearDown clear the benchmark state, this is not timed. 20 | TearDown(ctx context.Context) error 21 | } 22 | 23 | var benchmarks = make(map[string]Benchmark) 24 | 25 | func RegisterBenchmark(benchmark Benchmark) { 26 | if _, ok := benchmarks[benchmark.Name()]; ok { 27 | panic("Benchmark with this name already exists") 28 | } 29 | 30 | benchmarks[benchmark.Name()] = benchmark 31 | } 32 | 33 | func GetBenchmarks() map[string]Benchmark { 34 | return benchmarks 35 | } 36 | -------------------------------------------------------------------------------- /internal/backend/connector_state_read.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/ProtonMail/gluon/db" 8 | "github.com/ProtonMail/gluon/imap" 9 | "github.com/bradenaw/juniper/xslices" 10 | ) 11 | 12 | type DBIMAPStateRead struct { 13 | rd db.ReadOnly 14 | delimiter string 15 | } 16 | 17 | func (d *DBIMAPStateRead) GetSettings(ctx context.Context) (string, bool, error) { 18 | return d.rd.GetConnectorSettings(ctx) 19 | } 20 | 21 | func (d *DBIMAPStateRead) GetMailboxCount(ctx context.Context) (int, error) { 22 | return d.rd.GetMailboxCount(ctx) 23 | } 24 | 25 | func (d *DBIMAPStateRead) GetMailboxesWithoutAttrib(ctx context.Context) ([]imap.MailboxNoAttrib, error) { 26 | mboxes, err := d.rd.GetAllMailboxesNameAndRemoteID(ctx) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return xslices.Map(mboxes, func(m db.MailboxNameAndRemoteID) imap.MailboxNoAttrib { 32 | return imap.MailboxNoAttrib{ 33 | ID: m.RemoteID, 34 | Name: strings.Split(m.Name, d.delimiter), 35 | } 36 | }), nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/session/handle_uid.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/contexts" 8 | "github.com/ProtonMail/gluon/internal/response" 9 | "github.com/ProtonMail/gluon/internal/state" 10 | ) 11 | 12 | func (s *Session) handleUID(ctx context.Context, tag string, cmd *command.UID, mailbox *state.Mailbox, ch chan response.Response) (response.Response, error) { 13 | switch cmd := cmd.Command.(type) { 14 | case *command.Copy: 15 | return s.handleCopy(contexts.AsUID(ctx), tag, cmd, mailbox, ch) 16 | 17 | case *command.Move: 18 | return s.handleMove(contexts.AsUID(ctx), tag, cmd, mailbox, ch) 19 | 20 | case *command.Fetch: 21 | return s.handleFetch(contexts.AsUID(ctx), tag, cmd, mailbox, ch) 22 | 23 | case *command.Search: 24 | return s.handleSearch(contexts.AsUID(ctx), tag, cmd, mailbox, ch) 25 | 26 | case *command.Store: 27 | return s.handleStore(contexts.AsUID(ctx), tag, cmd, mailbox, ch) 28 | 29 | default: 30 | panic("bad command") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/imap_benchmarks/server/remote.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/ProtonMail/gluon/profiling" 8 | ) 9 | 10 | // RemoteServer can't control the start or stopping of the server but can still be used to run the benchmarks 11 | // against an existing server. 12 | type RemoteServer struct { 13 | address net.Addr 14 | } 15 | 16 | func (*RemoteServer) Close(ctx context.Context) error { 17 | return nil 18 | } 19 | 20 | func (r *RemoteServer) Address() net.Addr { 21 | return r.address 22 | } 23 | 24 | type RemoteServerBuilder struct { 25 | address net.Addr 26 | } 27 | 28 | func NewRemoteServerBuilder(address string) (*RemoteServerBuilder, error) { 29 | addr, err := net.ResolveTCPAddr("tcp", address) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &RemoteServerBuilder{address: addr}, nil 35 | } 36 | 37 | func (r *RemoteServerBuilder) New(ctx context.Context, serverPath string, profiler profiling.CmdProfilerBuilder) (Server, error) { 38 | return &RemoteServer{address: r.address}, nil 39 | } 40 | -------------------------------------------------------------------------------- /async/panic_handler_test.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | type recoverHandler struct{} 11 | 12 | func (h recoverHandler) HandlePanic(r interface{}) { 13 | fmt.Println("recoverHandler", r) 14 | } 15 | 16 | func TestPanicHandler(t *testing.T) { 17 | require.NotPanics(t, func() { 18 | defer HandlePanic(recoverHandler{}) 19 | panic("there") 20 | }) 21 | 22 | require.PanicsWithValue(t, "where", func() { 23 | defer HandlePanic(NoopPanicHandler{}) 24 | panic("where") 25 | }) 26 | 27 | require.PanicsWithValue(t, "everywhere", func() { 28 | defer HandlePanic(nil) 29 | panic("everywhere") 30 | }) 31 | 32 | require.NotPanics(t, func() { 33 | defer HandlePanic(recoverHandler{}) 34 | panic(nil) 35 | }) 36 | 37 | require.NotPanics(t, func() { 38 | defer HandlePanic(recoverHandler{}) 39 | }) 40 | 41 | require.NotPanics(t, func() { 42 | defer HandlePanic(NoopPanicHandler{}) 43 | }) 44 | 45 | require.NotPanics(t, func() { 46 | defer HandlePanic(&NoopPanicHandler{}) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /imap/update_waiter.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Waiter interface { 8 | // Wait waits until the update has been marked as done. 9 | Wait() (error, bool) 10 | 11 | // WaitContext waits until the update has been marked as done or the context is cancelled. 12 | WaitContext(context.Context) (error, bool) 13 | 14 | // Done marks the update as done and report an error (if any). 15 | Done(error) 16 | } 17 | 18 | type updateWaiter struct { 19 | waitCh chan error 20 | } 21 | 22 | func newUpdateWaiter() *updateWaiter { 23 | return &updateWaiter{ 24 | waitCh: make(chan error, 1), 25 | } 26 | } 27 | 28 | func (w *updateWaiter) Wait() (error, bool) { 29 | err, ok := <-w.waitCh 30 | return err, ok 31 | } 32 | 33 | func (w *updateWaiter) WaitContext(ctx context.Context) (error, bool) { 34 | select { 35 | case <-ctx.Done(): 36 | return nil, false 37 | case err, ok := <-w.waitCh: 38 | return err, ok 39 | } 40 | } 41 | 42 | func (w *updateWaiter) Done(err error) { 43 | if err != nil { 44 | w.waitCh <- err 45 | } 46 | 47 | close(w.waitCh) 48 | } 49 | -------------------------------------------------------------------------------- /internal/response/ok.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type ok struct { 8 | tag string 9 | msg string 10 | items []Item 11 | } 12 | 13 | func Ok(withTag ...string) *ok { 14 | var tag string 15 | 16 | if len(withTag) > 0 { 17 | tag = withTag[0] 18 | } else { 19 | tag = "*" 20 | } 21 | 22 | return &ok{ 23 | tag: tag, 24 | } 25 | } 26 | 27 | func (r *ok) WithMessage(msg string) *ok { 28 | r.msg = msg 29 | return r 30 | } 31 | 32 | func (r *ok) WithItems(items ...Item) *ok { 33 | r.items = append(r.items, items...) 34 | return r 35 | } 36 | 37 | func (r *ok) Send(s Session) error { 38 | return s.WriteResponse(r.String()) 39 | } 40 | 41 | func (r *ok) String() string { 42 | parts := []string{r.tag, "OK"} 43 | 44 | if len(r.items) > 0 { 45 | var items []string 46 | 47 | for _, item := range r.items { 48 | items = append(items, item.String()) 49 | } 50 | 51 | parts = append(parts, fmt.Sprintf("[%v]", join(items))) 52 | } 53 | 54 | if r.msg != "" { 55 | parts = append(parts, r.msg) 56 | } 57 | 58 | return join(parts) 59 | } 60 | -------------------------------------------------------------------------------- /internal/session/handle_create.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/ProtonMail/gluon/imap/command" 9 | "github.com/ProtonMail/gluon/internal/response" 10 | "github.com/ProtonMail/gluon/observability" 11 | "github.com/ProtonMail/gluon/observability/metrics" 12 | "github.com/ProtonMail/gluon/profiling" 13 | ) 14 | 15 | func (s *Session) handleCreate(ctx context.Context, tag string, cmd *command.Create, ch chan response.Response) error { 16 | profiling.Start(ctx, profiling.CmdTypeCreate) 17 | defer profiling.Stop(ctx, profiling.CmdTypeCreate) 18 | 19 | nameUTF8, err := s.decodeMailboxName(cmd.Mailbox) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if strings.EqualFold(nameUTF8, imap.Inbox) { 25 | return ErrCreateInbox 26 | } 27 | 28 | if err := s.state.Create(ctx, nameUTF8); err != nil { 29 | observability.AddMessageRelatedMetric(ctx, metrics.GenerateFailedToCreateMailbox()) 30 | return err 31 | } 32 | 33 | ch <- response.Ok(tag).WithMessage("CREATE") 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/response/recent.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type recent struct { 8 | count uint32 9 | } 10 | 11 | func Recent() *recent { 12 | return &recent{} 13 | } 14 | 15 | func (r *recent) WithCount(n uint32) *recent { 16 | r.count = n 17 | return r 18 | } 19 | 20 | func (r *recent) Send(s Session) error { 21 | return s.WriteResponse(r.String()) 22 | } 23 | 24 | func (r *recent) String() string { 25 | return fmt.Sprintf("* %v RECENT", r.count) 26 | } 27 | 28 | func (r *recent) canSkip(other Response) bool { 29 | if _, isExists := other.(*exists); isExists { 30 | return true 31 | } 32 | 33 | if _, isFetch := other.(*fetch); isFetch { 34 | return true 35 | } 36 | 37 | return false 38 | } 39 | 40 | func (r *recent) mergeWith(other Response) Response { 41 | otherRecent, ok := other.(*recent) 42 | if !ok { 43 | return nil 44 | } 45 | 46 | if otherRecent.count > r.count { 47 | panic(fmt.Sprintf( 48 | "consecutive recents must be non-decreasing, but had %d and new %d", 49 | otherRecent.count, r.count, 50 | )) 51 | } 52 | 53 | return r 54 | } 55 | -------------------------------------------------------------------------------- /imap/command/input_collector_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ProtonMail/gluon/rfcparser" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestInputCollector(t *testing.T) { 14 | input := toIMAPLine(`A003 APPEND saved-messages (\Seen) "15-Nov-1984 13:37:01 +0730" {23}`, `My message body is here`) 15 | source := bufio.NewReader(bytes.NewReader(input)) 16 | collector := NewInputCollector(source) 17 | 18 | s := rfcparser.NewScannerWithReader(collector) 19 | p := NewParser(s) 20 | 21 | expected := Command{Tag: "A003", Payload: &Append{ 22 | Mailbox: "saved-messages", 23 | Flags: []string{`\Seen`}, 24 | Literal: []byte("My message body is here"), 25 | DateTime: buildAppendDateTime(1984, time.November, 15, 13, 37, 1, 07, 30, false), 26 | }} 27 | 28 | cmd, err := p.Parse() 29 | require.NoError(t, err) 30 | require.Equal(t, expected, cmd) 31 | require.Equal(t, "append", p.LastParsedCommand()) 32 | require.Equal(t, "A003", p.LastParsedTag()) 33 | require.Equal(t, input, collector.Bytes()) 34 | } 35 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/benchmark/bench_dir_config.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // BenchDirConfig controls the directory where the benchmark data will be generated. 8 | type BenchDirConfig interface { 9 | Get() (string, error) 10 | } 11 | 12 | // FixedBenchDirConfig always returns a known path. 13 | type FixedBenchDirConfig struct { 14 | path string 15 | } 16 | 17 | func NewFixedBenchDirConfig(path string) *FixedBenchDirConfig { 18 | return &FixedBenchDirConfig{path: path} 19 | } 20 | 21 | func (p *FixedBenchDirConfig) Get() (string, error) { 22 | if err := os.MkdirAll(p.path, 0o777); err != nil { 23 | return "", err 24 | } 25 | 26 | return p.path, nil 27 | } 28 | 29 | // TmpBenchDirConfig returns a temporary path that is generated on first use. 30 | type TmpBenchDirConfig struct { 31 | path string 32 | } 33 | 34 | func (t *TmpBenchDirConfig) Get() (string, error) { 35 | if len(t.path) == 0 { 36 | path, err := os.MkdirTemp("", "gluon-bench-*") 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | t.path = path 42 | } 43 | 44 | return t.path, nil 45 | } 46 | -------------------------------------------------------------------------------- /watcher/watcher.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/ProtonMail/gluon/async" 7 | ) 8 | 9 | type Watcher[T any] struct { 10 | types map[reflect.Type]struct{} 11 | eventCh *async.QueuedChannel[T] 12 | } 13 | 14 | func New[T any](panicHandler async.PanicHandler, ofType ...T) *Watcher[T] { 15 | types := make(map[reflect.Type]struct{}, len(ofType)) 16 | 17 | for _, t := range ofType { 18 | types[reflect.TypeOf(t)] = struct{}{} 19 | } 20 | 21 | return &Watcher[T]{ 22 | types: types, 23 | eventCh: async.NewQueuedChannel[T](1, 1, panicHandler, "Gluon Watcher"), 24 | } 25 | } 26 | 27 | func (w *Watcher[T]) IsWatching(event T) bool { 28 | if len(w.types) == 0 { 29 | return true 30 | } 31 | 32 | _, ok := w.types[reflect.TypeOf(event)] 33 | 34 | return ok 35 | } 36 | 37 | func (w *Watcher[T]) GetChannel() <-chan T { 38 | return w.eventCh.GetChannel() 39 | } 40 | 41 | func (w *Watcher[T]) Send(event T) bool { 42 | return w.eventCh.Enqueue(event) 43 | } 44 | 45 | func (w *Watcher[T]) Close() { 46 | w.eventCh.CloseAndDiscardQueued() 47 | w.eventCh.Wait() 48 | } 49 | -------------------------------------------------------------------------------- /internal/response/ok_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestOkUntagged(t *testing.T) { 11 | assert.Equal(t, `* OK`, Ok().String()) 12 | } 13 | 14 | func TestOkTagged(t *testing.T) { 15 | assert.Equal(t, `tag OK`, Ok("tag").String()) 16 | } 17 | 18 | func TestOkUnseen(t *testing.T) { 19 | assert.Equal(t, `* OK [UNSEEN 17]`, Ok().WithItems(ItemUnseen(17)).String()) 20 | } 21 | 22 | func TestOkPermanentFlags(t *testing.T) { 23 | assert.Equal(t, `* OK [PERMANENTFLAGS (\Deleted)]`, Ok().WithItems(ItemPermanentFlags(imap.NewFlagSet(`\Deleted`))).String()) 24 | } 25 | 26 | func TestOkUIDNext(t *testing.T) { 27 | assert.Equal(t, `* OK [UIDNEXT 4392]`, Ok().WithItems(ItemUIDNext(4392)).String()) 28 | } 29 | 30 | func TestOkUIDValidity(t *testing.T) { 31 | assert.Equal(t, `* OK [UIDVALIDITY 3857529045]`, Ok().WithItems(ItemUIDValidity(3857529045)).String()) 32 | } 33 | 34 | func TestOkReadOnly(t *testing.T) { 35 | assert.Equal(t, `* OK [READ-ONLY]`, Ok().WithItems(ItemReadOnly()).String()) 36 | } 37 | -------------------------------------------------------------------------------- /internal/db_impl/sqlite3/v2/migration.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/ProtonMail/gluon/internal/db_impl/sqlite3/utils" 9 | ) 10 | 11 | type Migration struct{} 12 | 13 | func (m Migration) Run(ctx context.Context, tx utils.QueryWrapper, _ imap.UIDValidityGenerator) error { 14 | query := fmt.Sprintf("CREATE TABLE `%v` (`%v` INTEGER NOT NULL PRIMARY KEY , `%v` TEXT)", 15 | ConnectorSettingsTableName, 16 | ConnectorSettingsFieldID, 17 | ConnectorSettingsFieldValue, 18 | ) 19 | 20 | if _, err := utils.ExecQuery(ctx, tx, query); err != nil { 21 | return fmt.Errorf("failed to create connector settings table: %w", err) 22 | } 23 | 24 | query = fmt.Sprintf( 25 | "INSERT INTO %v (`%v`, `%v`) VALUES (?,NULL)", 26 | ConnectorSettingsTableName, 27 | ConnectorSettingsFieldID, 28 | ConnectorSettingsFieldValue, 29 | ) 30 | 31 | if _, err := utils.ExecQuery(ctx, tx, query, ConnectorSettingsDefaultID); err != nil { 32 | return fmt.Errorf("failed to create default connector settings entry: %w", err) 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /imap/command/lsub.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type LSub struct { 10 | Mailbox string 11 | LSubMailbox string 12 | } 13 | 14 | func (l LSub) String() string { 15 | return fmt.Sprintf("LSUB '%v' '%v'", l.Mailbox, l.LSubMailbox) 16 | } 17 | 18 | func (l LSub) SanitizedString() string { 19 | return l.String() 20 | } 21 | 22 | type LSubCommandParser struct{} 23 | 24 | func (LSubCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 25 | // lsub = "LSUB" SP mailbox SP list-mailbox 26 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 27 | return nil, err 28 | } 29 | 30 | mailbox, err := ParseMailbox(p) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after mailbox"); err != nil { 36 | return nil, err 37 | } 38 | 39 | listMailbox, err := parseListMailbox(p) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return &LSub{ 45 | Mailbox: mailbox.Value, 46 | LSubMailbox: listMailbox.Value, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/response/exists.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/imap" 7 | ) 8 | 9 | type exists struct { 10 | count imap.SeqID 11 | } 12 | 13 | func Exists() *exists { 14 | return &exists{} 15 | } 16 | 17 | func (r *exists) WithCount(n imap.SeqID) *exists { 18 | r.count = n 19 | return r 20 | } 21 | 22 | func (r *exists) Send(s Session) error { 23 | return s.WriteResponse(r.String()) 24 | } 25 | 26 | func (r *exists) String() string { 27 | return fmt.Sprintf("* %v EXISTS", r.count) 28 | } 29 | 30 | func (r *exists) canSkip(other Response) bool { 31 | if _, isRecent := other.(*recent); isRecent { 32 | return true 33 | } 34 | 35 | if _, isFetch := other.(*fetch); isFetch { 36 | return true 37 | } 38 | 39 | return false 40 | } 41 | 42 | func (r *exists) mergeWith(other Response) Response { 43 | otherExist, ok := other.(*exists) 44 | if !ok { 45 | return nil 46 | } 47 | 48 | if otherExist.count > r.count { 49 | panic(fmt.Sprintf( 50 | "consecutive exists must be non-decreasing, but had %d and new %d", 51 | otherExist.count, r.count, 52 | )) 53 | } 54 | 55 | return r 56 | } 57 | -------------------------------------------------------------------------------- /internal/response/no.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "fmt" 4 | 5 | type no struct { 6 | tag string 7 | err error 8 | items []Item 9 | } 10 | 11 | func No(withTag ...string) *no { 12 | var tag string 13 | 14 | if len(withTag) > 0 { 15 | tag = withTag[0] 16 | } else { 17 | tag = "*" 18 | } 19 | 20 | return &no{ 21 | tag: tag, 22 | } 23 | } 24 | 25 | func (r *no) WithItems(items ...Item) *no { 26 | r.items = append(r.items, items...) 27 | return r 28 | } 29 | 30 | func (r *no) WithError(err error) *no { 31 | r.err = err 32 | return r 33 | } 34 | 35 | func (r *no) Send(s Session) error { 36 | return s.WriteResponse(r.String()) 37 | } 38 | 39 | func (r *no) String() (res string) { 40 | parts := []string{r.tag, "NO"} 41 | 42 | if len(r.items) > 0 { 43 | var items []string 44 | 45 | for _, item := range r.items { 46 | items = append(items, item.String()) 47 | } 48 | 49 | parts = append(parts, fmt.Sprintf("[%v]", join(items))) 50 | } 51 | 52 | if r.err != nil { 53 | parts = append(parts, r.err.Error()) 54 | } 55 | 56 | return join(parts) 57 | } 58 | 59 | func (r *no) Error() string { 60 | return r.err.Error() 61 | } 62 | -------------------------------------------------------------------------------- /imap/command/copy.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Copy struct { 10 | SeqSet []SeqRange 11 | Mailbox string 12 | } 13 | 14 | func (l Copy) String() string { 15 | return fmt.Sprintf("COPY %v '%v'", l.SeqSet, l.Mailbox) 16 | } 17 | 18 | func (l Copy) SanitizedString() string { 19 | return fmt.Sprintf("COPY %v '%v'", l.SeqSet, sanitizeString(l.Mailbox)) 20 | } 21 | 22 | type CopyCommandParser struct{} 23 | 24 | func (CopyCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 25 | // copy = "COPY" SP sequence-set SP mailbox 26 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 27 | return nil, err 28 | } 29 | 30 | seqSet, err := ParseSeqSet(p) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after seqset"); err != nil { 36 | return nil, err 37 | } 38 | 39 | mailbox, err := ParseMailbox(p) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return &Copy{ 45 | SeqSet: seqSet, 46 | Mailbox: mailbox.Value, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /imap/command/flags_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ProtonMail/gluon/rfcparser" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParser_ParseFlagList(t *testing.T) { 12 | values := map[string][]string{ 13 | `(\Answered)`: {`\Answered`}, 14 | `(\Answered Foo \Something)`: {`\Answered`, `Foo`, `\Something`}, 15 | `()`: nil, 16 | } 17 | 18 | for input, expected := range values { 19 | p := rfcparser.NewParser(rfcparser.NewScanner(bytes.NewReader([]byte(input)))) 20 | require.NoError(t, p.Advance()) 21 | v, err := ParseFlagList(p) 22 | require.NoError(t, err) 23 | require.Equal(t, expected, v) 24 | } 25 | } 26 | 27 | func TestParser_ParseFlagListInvalid(t *testing.T) { 28 | inputs := [][]byte{ 29 | []byte(`(\Foo\Bar)`), 30 | []byte(`"(\Recent)`), 31 | []byte(`(\Foo )`), 32 | []byte(`(\Foo`), 33 | } 34 | for _, i := range inputs { 35 | p := rfcparser.NewParser(rfcparser.NewScanner(bytes.NewReader([]byte(i)))) 36 | require.NoError(t, p.Advance()) 37 | 38 | _, err := ParseFlagList(p) 39 | require.Error(t, err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /imap/command/move.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Move struct { 10 | SeqSet []SeqRange 11 | Mailbox string 12 | } 13 | 14 | func (l Move) String() string { 15 | return fmt.Sprintf("MOVE %v '%v'", l.SeqSet, l.Mailbox) 16 | } 17 | 18 | func (l Move) SanitizedString() string { 19 | return fmt.Sprintf("MOVE %v '%v'", l.SeqSet, sanitizeString(l.Mailbox)) 20 | } 21 | 22 | type MoveCommandParser struct{} 23 | 24 | func (MoveCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 25 | // move = "MOVE" SP sequence-set SP mailbox 26 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 27 | return nil, err 28 | } 29 | 30 | seqSet, err := ParseSeqSet(p) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after seqset"); err != nil { 36 | return nil, err 37 | } 38 | 39 | mailbox, err := ParseMailbox(p) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return &Move{ 45 | SeqSet: seqSet, 46 | Mailbox: mailbox.Value, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /rfc5322/miscelleaneous_test.go: -------------------------------------------------------------------------------- 1 | package rfc5322 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bradenaw/juniper/xslices" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParseWord(t *testing.T) { 11 | inputs := map[string]string{ 12 | `"f\".c"`: "f\".c", 13 | "\" \r\n f\\\".c\r\n \"": " f\".c ", 14 | ` " foo bar derer " `: " foo bar derer ", 15 | `foo`: "foo", 16 | } 17 | 18 | for i, e := range inputs { 19 | p := newTestRFCParser(i) 20 | v, err := parseWord(p) 21 | require.NoError(t, err) 22 | require.Equal(t, e, v.String.Value) 23 | } 24 | } 25 | 26 | func TestParsePhrase(t *testing.T) { 27 | inputs := map[string][]string{ 28 | `foo "quoted"`: {"foo", "quoted"}, 29 | `"f\".c" "quoted"`: {"f\".c", "quoted"}, 30 | `foo bar`: {"foo", "bar"}, 31 | `foo.bar`: {"foo", ".", "bar"}, 32 | `foo . bar`: {"foo", ".", "bar"}, 33 | } 34 | 35 | for i, e := range inputs { 36 | p := newTestRFCParser(i) 37 | v, err := parsePhrase(p) 38 | require.NoError(t, err) 39 | require.Equal(t, e, xslices.Map(v, func(v parserString) string { return v.String.Value })) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 James Houlahan 4 | Copyright (c) 2021 Proton AG 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /imap/command/rename.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Rename struct { 10 | From string 11 | To string 12 | } 13 | 14 | func (l Rename) String() string { 15 | return fmt.Sprintf("RENAME '%v' '%v'", l.From, l.To) 16 | } 17 | 18 | func (l Rename) SanitizedString() string { 19 | return fmt.Sprintf("RENAME '%v' '%v'", sanitizeString(l.From), sanitizeString(l.To)) 20 | } 21 | 22 | type RenameCommandParser struct{} 23 | 24 | func (RenameCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 25 | // rename = "RENAME" SP mailbox SP mailbox 26 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 27 | return nil, err 28 | } 29 | 30 | mailboxFrom, err := ParseMailbox(p) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after mailbox"); err != nil { 36 | return nil, err 37 | } 38 | 39 | mailboxTo, err := ParseMailbox(p) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return &Rename{ 45 | From: mailboxFrom.Value, 46 | To: mailboxTo.Value, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/session/handle_delete.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/ProtonMail/gluon/imap/command" 9 | "github.com/ProtonMail/gluon/internal/response" 10 | "github.com/ProtonMail/gluon/observability" 11 | "github.com/ProtonMail/gluon/observability/metrics" 12 | "github.com/ProtonMail/gluon/profiling" 13 | ) 14 | 15 | func (s *Session) handleDelete(ctx context.Context, tag string, cmd *command.Delete, ch chan response.Response) error { 16 | profiling.Start(ctx, profiling.CmdTypeDelete) 17 | defer profiling.Stop(ctx, profiling.CmdTypeDelete) 18 | 19 | nameUTF8, err := s.decodeMailboxName(cmd.Mailbox) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if strings.EqualFold(nameUTF8, imap.Inbox) { 25 | return ErrDeleteInbox 26 | } 27 | 28 | selectedDeleted, err := s.state.Delete(ctx, nameUTF8) 29 | if err != nil { 30 | observability.AddOtherMetric(ctx, metrics.GenerateFailedToDeleteMailboxMetric()) 31 | return err 32 | } 33 | 34 | ch <- response.Ok(tag).WithMessage("DELETE") 35 | 36 | if selectedDeleted { 37 | ch <- response.Bye().WithMailboxDeleted() 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /imap/testdata/bounds.eml: -------------------------------------------------------------------------------- 1 | Content-Type: multipart/mixed; 2 | boundary=eccb619eb8cb1994c1375b3b013a7c995dcc7ab05f9cfec486ffeb18d6f8ca65 3 | To: 4 | From: 5 | Subject: empty header test 6 | Date: Tue, 17 Aug 2021 07:33:19 -0700 (PDT) 7 | Mime-Version: 1.0 8 | 9 | --eccb619eb8cb1994c1375b3b013a7c995dcc7ab05f9cfec486ffeb18d6f8ca65 10 | Content-Transfer-Encoding: quoted-printable 11 | Content-Type: text/plain; charset=utf-8 12 | 13 | No valid message body or attachments were found in this email. Please check= 14 | with the sender to ensure that they are sending valid emails. The raw mess= 15 | age is attached.\n\n 16 | --eccb619eb8cb1994c1375b3b013a7c995dcc7ab05f9cfec486ffeb18d6f8ca65 17 | Content-Disposition: attachment; filename=message.eml 18 | Content-Type: message/rfc822; name=message.eml 19 | 20 | Return-Path: 21 | X-Original-To: someone@pm.me 22 | Date: Tue, 17 Aug 2021 07:33:19 -0700 (PDT) 23 | To: someone@pm.me 24 | From: someone@gmail.com 25 | Subject: empty header test 26 | Content-type: multipart/mixed; boundary=whatever 27 | 28 | --whatever 29 | Body 30 | --whatever 31 | 32 | --eccb619eb8cb1994c1375b3b013a7c995dcc7ab05f9cfec486ffeb18d6f8ca65-- 33 | -------------------------------------------------------------------------------- /benchmarks/imaptest/benchmark.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | 4 | cases: 5 | - users: 1 6 | clients: 1 7 | settings: 8 | - simple 9 | - full 10 | - users: 1 11 | clients: 10 12 | settings: 13 | - simple 14 | - users: 1 15 | clients: 100 16 | settings: 17 | - simple 18 | - users: 10 19 | clients: 10 20 | settings: 21 | - simple 22 | - users: 10 23 | clients: 100 24 | settings: 25 | - simple 26 | - users: 100 27 | clients: 100 28 | settings: 29 | - simple 30 | 31 | settings: 32 | simple: 33 | mbox: dovecot-crlf 34 | secs: 10 35 | no_pipelining: true 36 | simple-with-checks: 37 | mbox: dovecot-crlf 38 | secs: 10 39 | checkpoint: 3 40 | no_pipelining: true 41 | own_msgs: true 42 | own_flags: true 43 | full: 44 | mbox: dovecot-crlf 45 | no_pipelining: false 46 | secs: 60 47 | mcreate: 50 48 | mdelete: 50 49 | uidf: 50 50 | search: 30 51 | noop: 15 52 | fetch: 50 53 | login: 100 54 | logout: 100 55 | list: 50 56 | select: 100 57 | fet2: 100,30 58 | copy: 30,5 59 | store: 50 60 | delete: 100 61 | expunge: 100 62 | append: 100,5 63 | -------------------------------------------------------------------------------- /internal/db_impl/sqlite3/client_test.go: -------------------------------------------------------------------------------- 1 | package sqlite3 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestClient_DBConnectionSpecialCharacterPath(t *testing.T) { 12 | dbDirs := []string{ 13 | "#test", 14 | "test_test", 15 | "test#test#test", 16 | "test$test$test", 17 | } 18 | 19 | testingDir := t.TempDir() 20 | 21 | for _, dirName := range dbDirs { 22 | path := filepath.Join(testingDir, dirName) 23 | if err := os.MkdirAll(path, 0777); err != nil { 24 | fmt.Println("Could not create testing directory, error: ", err) 25 | t.FailNow() 26 | } 27 | 28 | filePath := filepath.Join(path, "test.db") 29 | 30 | client, err := sql.Open("sqlite3", getDatabaseConn("test", "test", filePath)) 31 | if err != nil { 32 | fmt.Println("Could not connect to test database, error: ", err) 33 | t.FailNow() 34 | } 35 | 36 | if err := client.Ping(); err != nil { 37 | fmt.Println("Could not ping test database, error: ", err) 38 | client.Close() 39 | t.FailNow() 40 | } 41 | 42 | if err := client.Close(); err != nil { 43 | fmt.Println("Could not close test database, error: ", err) 44 | t.FailNow() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/testdata/embedded-rfc822.eml: -------------------------------------------------------------------------------- 1 | From: Nathaniel Borenstein 2 | To: Ned Freed 3 | Date: 1 Jan 1970 00:00:00 +0000 4 | Subject: Sample message 5 | MIME-Version: 1.0 6 | Content-type: multipart/mixed; boundary="simple boundary" 7 | 8 | This is the preamble. It is to be ignored, though it 9 | is a handy place for mail composers to include an 10 | explanatory note to non-MIME compliant readers. 11 | --simple boundary 12 | Content-type: text/plain; charset=us-ascii 13 | 14 | This part does not end with a linebreak. 15 | --simple boundary 16 | Content-Disposition: attachment; filename=test.eml 17 | Content-Type: message/rfc822; name=test.eml 18 | X-Pm-Content-Encryption: on-import 19 | 20 | To: someone 21 | Subject: Fwd: embedded 22 | Content-type: multipart/mixed; boundary="embedded-boundary" 23 | 24 | --embedded-boundary 25 | Content-type: text/plain; charset=us-ascii 26 | 27 | This part is embedded 28 | 29 | -- 30 | From me 31 | --embedded-boundary 32 | Content-type: text/plain; charset=us-ascii 33 | 34 | This part is also embedded 35 | --embedded-boundary-- 36 | 37 | --simple boundary-- 38 | This is the epilogue. It is also to be ignored. 39 | -------------------------------------------------------------------------------- /tests/status_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestStatus(t *testing.T) { 8 | runOneToOneTestWithAuth(t, defaultServerOptions(t, withDelimiter(".")), func(c *testConnection, _ *testSession) { 9 | c.C("B001 CREATE blurdybloop") 10 | c.S("B001 OK CREATE") 11 | 12 | c.doAppend(`blurdybloop`, buildRFC5322TestLiteral(`To: 1@pm.me`), `\Seen`).expect("OK") 13 | c.doAppend(`blurdybloop`, buildRFC5322TestLiteral(`To: 2@pm.me`)).expect("OK") 14 | c.doAppend(`blurdybloop`, buildRFC5322TestLiteral(`To: 3@pm.me`)).expect("OK") 15 | 16 | c.C("A042 STATUS blurdybloop (MESSAGES UNSEEN)") 17 | c.S(`* STATUS "blurdybloop" (MESSAGES 3 UNSEEN 2)`) 18 | c.S("A042 OK STATUS") 19 | }) 20 | } 21 | 22 | func TestStatusWithUtf8MailboxNames(t *testing.T) { 23 | runOneToOneTestWithAuth(t, defaultServerOptions(t, withDelimiter(".")), func(c *testConnection, s *testSession) { 24 | s.mailboxCreated("user", []string{"mbox-öüäëæøå"}) 25 | s.flush("user") 26 | c.doAppend(`mbox-&APYA,ADkAOsA5gD4AOU-`, buildRFC5322TestLiteral(`To: 1@pm.me`)).expect("OK") 27 | c.C(`a STATUS mbox-&APYA,ADkAOsA5gD4AOU- (MESSAGES)`) 28 | c.S(`* STATUS "mbox-&APYA,ADkAOsA5gD4AOU-" (MESSAGES 1)`) 29 | c.OK(`a`) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /tests/capability_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCapability(t *testing.T) { 8 | runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { 9 | c.C("A001 Capability") 10 | c.S(`* CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 STARTTLS`) 11 | c.S("A001 OK CAPABILITY") 12 | 13 | c.C(`A002 login "user" "pass"`) 14 | c.S(`A002 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) 15 | 16 | c.C("A003 Capability") 17 | c.S(`* CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT`) 18 | c.S("A003 OK CAPABILITY") 19 | }) 20 | } 21 | 22 | func TestCapabilityAuthenticateDisabled(t *testing.T) { 23 | runOneToOneTest(t, defaultServerOptions(t, withDisableIMAPAuthenticate()), func(c *testConnection, _ *testSession) { 24 | c.C("A001 Capability") 25 | c.S(`* CAPABILITY ID IDLE IMAP4rev1 STARTTLS`) 26 | c.S("A001 OK CAPABILITY") 27 | 28 | c.C(`A002 login "user" "pass"`) 29 | c.S(`A002 OK [CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) 30 | 31 | c.C("A003 Capability") 32 | c.S(`* CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT`) 33 | c.S("A003 OK CAPABILITY") 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /imap/command/login.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/rfcparser" 7 | ) 8 | 9 | type Login struct { 10 | UserID string 11 | Password string 12 | } 13 | 14 | func (l Login) String() string { 15 | return fmt.Sprintf("LOGIN '%v' '%v'", l.UserID, l.Password) 16 | } 17 | 18 | func (l Login) SanitizedString() string { 19 | return fmt.Sprintf("LOGIN '%v' ", sanitizeString(l.UserID)) 20 | } 21 | 22 | type LoginCommandParser struct{} 23 | 24 | func (LoginCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { 25 | // login = "LOGIN" SP userid SP password 26 | // userid = astring 27 | // password = astring 28 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { 29 | return nil, err 30 | } 31 | 32 | user, err := p.ParseAString() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | if err := p.Consume(rfcparser.TokenTypeSP, "expected space after userid"); err != nil { 38 | return nil, err 39 | } 40 | 41 | password, err := p.ParseAString() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return &Login{ 47 | UserID: user.Value, 48 | Password: password.Value, 49 | }, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/session/handle_lsub.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/imap/command" 7 | "github.com/ProtonMail/gluon/internal/response" 8 | "github.com/ProtonMail/gluon/internal/state" 9 | "github.com/ProtonMail/gluon/profiling" 10 | "github.com/emersion/go-imap/utf7" 11 | ) 12 | 13 | func (s *Session) handleLsub(ctx context.Context, tag string, cmd *command.LSub, ch chan response.Response) error { 14 | profiling.Start(ctx, profiling.CmdTypeLSub) 15 | defer profiling.Stop(ctx, profiling.CmdTypeLSub) 16 | 17 | nameUTF8, err := s.decodeMailboxName(cmd.LSubMailbox) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | return s.state.List(ctx, cmd.Mailbox, nameUTF8, true, func(matches map[string]state.Match) error { 23 | for _, match := range matches { 24 | nameUtf7, err := utf7.Encoding.NewEncoder().String(match.Name) 25 | if err != nil { 26 | panic(err) 27 | } 28 | select { 29 | case ch <- response.Lsub(). 30 | WithName(nameUtf7). 31 | WithDelimiter(match.Delimiter). 32 | WithAttributes(match.Atts): 33 | 34 | case <-ctx.Done(): 35 | return ctx.Err() 36 | } 37 | } 38 | 39 | ch <- response.Ok(tag).WithMessage("LSUB") 40 | 41 | return nil 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /imap/command/input_collector.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/ProtonMail/gluon/rfcparser" 5 | ) 6 | 7 | type InputCollector struct { 8 | source rfcparser.Reader 9 | bytes []byte 10 | } 11 | 12 | func NewInputCollector(source rfcparser.Reader) *InputCollector { 13 | return &InputCollector{ 14 | source: source, 15 | bytes: make([]byte, 0, 128), 16 | } 17 | } 18 | 19 | func (i *InputCollector) Bytes() []byte { 20 | return i.bytes 21 | } 22 | 23 | func (i *InputCollector) Read(dst []byte) (int, error) { 24 | n, err := i.source.Read(dst) 25 | if err == nil { 26 | i.bytes = append(i.bytes, dst[0:n]...) 27 | } 28 | 29 | return n, err 30 | } 31 | 32 | func (i *InputCollector) ReadByte() (byte, error) { 33 | b, err := i.source.ReadByte() 34 | if err == nil { 35 | i.bytes = append(i.bytes, b) 36 | } 37 | 38 | return b, err 39 | } 40 | 41 | func (i *InputCollector) ReadBytes(delim byte) ([]byte, error) { 42 | b, err := i.source.ReadBytes(delim) 43 | if err == nil { 44 | i.bytes = append(i.bytes, b...) 45 | } 46 | 47 | return b, err 48 | } 49 | 50 | func (i *InputCollector) Reset() { 51 | i.bytes = i.bytes[:0] 52 | } 53 | 54 | func (i *InputCollector) SetSource(source rfcparser.Reader) { 55 | i.source = source 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ProtonMail/gluon 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/ProtonMail/go-mbox v1.1.0 7 | github.com/bradenaw/juniper v0.12.0 8 | github.com/emersion/go-imap v1.2.1 9 | github.com/emersion/go-imap-uidplus v0.0.0-20200503180755-e75854c361e9 10 | github.com/golang/mock v1.6.0 11 | github.com/google/uuid v1.3.0 12 | github.com/mattn/go-sqlite3 v1.14.22 13 | github.com/pierrec/lz4/v4 v4.1.17 14 | github.com/pkg/profile v1.7.0 15 | github.com/sirupsen/logrus v1.9.2 16 | github.com/stretchr/testify v1.8.3 17 | go.uber.org/goleak v1.2.1 18 | golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea 19 | golang.org/x/sys v0.8.0 20 | golang.org/x/text v0.9.0 21 | gopkg.in/yaml.v3 v3.0.1 22 | ) 23 | 24 | require ( 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect 27 | github.com/felixge/fgprof v0.9.3 // indirect 28 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect 29 | github.com/kr/pretty v0.3.1 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | github.com/rogpeppe/go-internal v1.10.0 // indirect 32 | golang.org/x/sync v0.2.0 // indirect 33 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /internal/state/errors.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNoSuchMessage = errors.New("no such message") 7 | ErrNoSuchMailbox = errors.New("no such mailbox") 8 | 9 | ErrExistingMailbox = errors.New("a mailbox with that name already exists") 10 | ErrAlreadySubscribed = errors.New("already subscribed to this mailbox") 11 | ErrAlreadyUnsubscribed = errors.New("not subscribed to this mailbox") 12 | ErrSessionNotSelected = errors.New("session is not selected") 13 | 14 | ErrOperationNotAllowed = errors.New("operation not allowed") 15 | ErrMailboxNameBeginsWithSeparator = errors.New("invalid mailbox name: begins with hierarchy separator") 16 | ErrMailboxNameAdjacentSeparator = errors.New("invalid mailbox name: has adjacent hierarchy separators") 17 | ) 18 | 19 | func IsStateError(err error) bool { 20 | return errors.Is(err, ErrNoSuchMailbox) || 21 | errors.Is(err, ErrNoSuchMessage) || 22 | errors.Is(err, ErrExistingMailbox) || 23 | errors.Is(err, ErrAlreadySubscribed) || 24 | errors.Is(err, ErrAlreadyUnsubscribed) || 25 | errors.Is(err, ErrSessionNotSelected) || 26 | errors.Is(err, ErrOperationNotAllowed) || 27 | errors.Is(err, ErrMailboxNameBeginsWithSeparator) || 28 | errors.Is(err, ErrMailboxNameAdjacentSeparator) 29 | } 30 | -------------------------------------------------------------------------------- /rfc5322/cfws_test.go: -------------------------------------------------------------------------------- 1 | package rfc5322 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestParseFWS(t *testing.T) { 10 | inputs := []string{ 11 | " \t ", 12 | "\r\n\t", 13 | " \r\n\t", 14 | " \r\n \r\n \r\n\t", 15 | " \t\r\n ", 16 | } 17 | 18 | for _, i := range inputs { 19 | p := newTestRFCParser(i) 20 | err := parseFWS(p) 21 | require.NoError(t, err) 22 | } 23 | } 24 | 25 | func TestParserComment(t *testing.T) { 26 | inputs := []string{ 27 | "(my comment here)", 28 | "(my comment here )", 29 | "( my comment here)", 30 | "( my comment here )", 31 | "(my\r\n comment here)", 32 | "(my\r\n (comment) here)", 33 | "(\\my\r\n (comment) here)", 34 | "(" + string([]byte{0x7F, 0x8}) + ")", 35 | } 36 | 37 | for _, i := range inputs { 38 | p := newTestRFCParser(i) 39 | err := parseComment(p) 40 | require.NoError(t, err) 41 | } 42 | } 43 | 44 | func TestParserCFWS(t *testing.T) { 45 | inputs := []string{ 46 | " ", 47 | "(my comment here)", 48 | " (my comment here) ", 49 | " \r\n (my comment here) ", 50 | " \r\n \r\n (my comment here) \r\n ", 51 | } 52 | 53 | for _, i := range inputs { 54 | p := newTestRFCParser(i) 55 | err := parseCFWS(p) 56 | require.NoError(t, err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/session/handle_list.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ProtonMail/gluon/imap/command" 8 | "github.com/ProtonMail/gluon/internal/response" 9 | "github.com/ProtonMail/gluon/internal/state" 10 | "github.com/ProtonMail/gluon/profiling" 11 | "github.com/emersion/go-imap/utf7" 12 | ) 13 | 14 | func (s *Session) handleList(ctx context.Context, tag string, cmd *command.List, ch chan response.Response) error { 15 | profiling.Start(ctx, profiling.CmdTypeList) 16 | defer profiling.Stop(ctx, profiling.CmdTypeList) 17 | 18 | nameUTF8, err := s.decodeMailboxName(cmd.ListMailbox) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | return s.state.List(ctx, cmd.Mailbox, nameUTF8, false, func(matches map[string]state.Match) error { 24 | for _, match := range matches { 25 | nameUtf7, err := utf7.Encoding.NewEncoder().String(match.Name) 26 | if err != nil { 27 | return fmt.Errorf("failed to convert name to utf7") 28 | } 29 | select { 30 | case ch <- response.List(). 31 | WithName(nameUtf7). 32 | WithDelimiter(match.Delimiter). 33 | WithAttributes(match.Atts): 34 | 35 | case <-ctx.Done(): 36 | return ctx.Err() 37 | } 38 | } 39 | 40 | ch <- response.Ok(tag).WithMessage("LIST") 41 | 42 | return nil 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/store_benchmarks/store_factory.go: -------------------------------------------------------------------------------- 1 | package store_benchmarks 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ProtonMail/gluon/store" 7 | ) 8 | 9 | type StoreBuilder interface { 10 | New(path string) (store.Store, error) 11 | } 12 | 13 | type storeFactory struct { 14 | builders map[string]StoreBuilder 15 | } 16 | 17 | func newStoreFactory() *storeFactory { 18 | return &storeFactory{builders: make(map[string]StoreBuilder)} 19 | } 20 | 21 | func (sf *storeFactory) Register(name string, builder StoreBuilder) error { 22 | if _, ok := sf.builders[name]; ok { 23 | return fmt.Errorf("builder already exists") 24 | } 25 | 26 | sf.builders[name] = builder 27 | 28 | return nil 29 | } 30 | 31 | func (sf *storeFactory) New(name, path string) (store.Store, error) { 32 | builder, ok := sf.builders[name] 33 | 34 | if !ok { 35 | return nil, fmt.Errorf("no such builder exists") 36 | } 37 | 38 | return builder.New(path) 39 | } 40 | 41 | var storeFactoryInstance = newStoreFactory() 42 | 43 | func RegisterStoreBuilder(name string, storeBuilder StoreBuilder) { 44 | if err := storeFactoryInstance.Register(name, storeBuilder); err != nil { 45 | panic(err) 46 | } 47 | } 48 | 49 | func NewStore(name, path string) (store.Store, error) { 50 | return storeFactoryInstance.New(name, path) 51 | } 52 | -------------------------------------------------------------------------------- /rfc822/testdata/hash_utf8.eml: -------------------------------------------------------------------------------- 1 | Content-Language: en-us 2 | X-Mailer: Microsoft Outlook 16.0 3 | Content-Type: multipart/alternative; 4 | boundary="----=_NextPart_000_00DF_01D9E49A.A45FAB50" 5 | To: foo@bar.com 6 | From: bar@foo.com 7 | Subject: =?utf-8?q?HTML_to_c=C3=B6nt=C3=A4ct_Subj=CE=B5=CE=AD=CF=82=CF=84_?= 8 | =?utf-8?q?=F0=9F=91=80_=F0=9F=8F=87_=E2=9A=BD_=F0=9F=91=8C=C2=B6_=C3=84_?= 9 | =?utf-8?q?=C3=88?= 10 | Mime-Version: 1.0 11 | Date: Mon, 11 Sep 2023 08:27:43 +0000 12 | 13 | ------=_NextPart_000_00DF_01D9E49A.A45FAB50 14 | Content-Type: text/plain; 15 | charset="utf-8" 16 | Content-Transfer-Encoding: 8bit 17 | 18 | HTML to cöntäct Subjεέςτ 👀 🏇 ⚽ 👌¶ Ä È 19 | 20 | Asdojasodjasodjasodja 21 | 22 | * Bullet 1 23 | 24 | * Bullet 1.1 25 | 26 | * Bullet 2 27 | 28 | * Bullet 2.1 29 | * Bullet 2.2 30 | 31 | * Bullet 2.2.1 32 | 33 | * Bullet 2.3 34 | 35 | * Bullet 3 36 | 37 | ¶ Ä È ¶ Ä È ¶ Ä È ¶ Ä È ¶ Ä È ¶ Ä È ¶ Ä È ¶ Ä È 38 | 39 | 40 | 41 | 1. Number 1 42 | 43 | 1. Number 1.1 44 | 45 | 2. Number 2 46 | 47 | 1. Number 2.1 48 | 2. Number 2.2 49 | 50 | 1. Number 2.2.1 51 | 52 | 3. Number 2.3 53 | 54 | 3. Number 3 55 | 56 | 57 | 58 | HTML HYPERLINK 59 | 60 | ------=_NextPart_000_00DF_01D9E49A.A45FAB50-- 61 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/imap_benchmarks/state_tracker.go: -------------------------------------------------------------------------------- 1 | package imap_benchmarks 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/emersion/go-imap/client" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type stateTracker struct { 11 | MBoxes []string 12 | } 13 | 14 | func newStateTracker() *stateTracker { 15 | return &stateTracker{} 16 | } 17 | 18 | func (s *stateTracker) createRandomMBox(cl *client.Client) (string, error) { 19 | mbox := "Folders/" + uuid.NewString() 20 | 21 | if err := cl.Create(mbox); err != nil { 22 | return "", err 23 | } 24 | 25 | s.MBoxes = append(s.MBoxes, mbox) 26 | 27 | return mbox, nil 28 | } 29 | 30 | func (s *stateTracker) createAndFillRandomMBox(cl *client.Client) (string, error) { 31 | mbox, err := s.createRandomMBox(cl) 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | if err := FillMailbox(cl, mbox); err != nil { 37 | return "", err 38 | } 39 | 40 | return mbox, nil 41 | } 42 | 43 | func (s *stateTracker) cleanup(cl *client.Client) error { 44 | for _, v := range s.MBoxes { 45 | if err := cl.Delete(v); err != nil { 46 | return err 47 | } 48 | } 49 | 50 | s.MBoxes = nil 51 | 52 | return nil 53 | } 54 | 55 | func (s *stateTracker) cleanupWithAddr(addr net.Addr) error { 56 | return WithClient(addr, func(c *client.Client) error { 57 | return s.cleanup(c) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /internal/ticker/ticker.go: -------------------------------------------------------------------------------- 1 | package ticker 2 | 3 | import "time" 4 | 5 | type Ticker struct { 6 | ticker *time.Ticker 7 | period time.Duration 8 | pollCh chan chan struct{} 9 | stopCh chan struct{} 10 | } 11 | 12 | func New(period time.Duration) *Ticker { 13 | return &Ticker{ 14 | ticker: time.NewTicker(period), 15 | period: period, 16 | pollCh: make(chan chan struct{}), 17 | stopCh: make(chan struct{}), 18 | } 19 | } 20 | 21 | // Pause pauses the ticker. 22 | func (ticker *Ticker) Pause() { 23 | ticker.ticker.Stop() 24 | } 25 | 26 | // Resume resumes the ticker. 27 | func (ticker *Ticker) Resume() { 28 | ticker.ticker.Reset(ticker.period) 29 | } 30 | 31 | // Poll polls the ticker. It blocks until the tick has been executed. 32 | func (ticker *Ticker) Poll() { 33 | doneCh := make(chan struct{}) 34 | ticker.pollCh <- doneCh 35 | <-doneCh 36 | } 37 | 38 | // Stop stops the ticker. 39 | func (ticker *Ticker) Stop() { 40 | close(ticker.stopCh) 41 | } 42 | 43 | // Tick calls the given callback at regular intervals or when the ticker is polled. 44 | func (ticker *Ticker) Tick(fn func(time.Time)) { 45 | for { 46 | select { 47 | case tick := <-ticker.ticker.C: 48 | fn(tick) 49 | 50 | case doneCh := <-ticker.pollCh: 51 | fn(time.Now()) 52 | close(doneCh) 53 | 54 | case <-ticker.stopCh: 55 | return 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /imap/uid_validity_generator_test.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/bradenaw/juniper/parallel" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "golang.org/x/exp/slices" 11 | ) 12 | 13 | func TestEpochUIDValidityGenerator_Generate(t *testing.T) { 14 | generator := DefaultEpochUIDValidityGenerator() 15 | 16 | const UIDCount = 10 17 | 18 | var uids = make([]UID, UIDCount) 19 | 20 | for i := 0; i < UIDCount; i++ { 21 | uid, err := generator.Generate() 22 | require.NoError(t, err) 23 | 24 | uids[i] = uid 25 | } 26 | 27 | time.Sleep(10 * time.Second) 28 | 29 | uid, err := generator.Generate() 30 | require.NoError(t, err) 31 | 32 | for i := 0; i < UIDCount-1; i++ { 33 | assert.Less(t, uids[i], uids[i+1]) 34 | } 35 | 36 | assert.Greater(t, uid, uids[UIDCount-1]) 37 | } 38 | 39 | func TestEpochUIDValidityGenerator_GenerateParallel(t *testing.T) { 40 | generator := DefaultEpochUIDValidityGenerator() 41 | 42 | const UIDCount = 1000 43 | 44 | var uids = make([]UID, UIDCount) 45 | 46 | parallel.Do(0, UIDCount, func(i int) { 47 | uid, err := generator.Generate() 48 | require.NoError(t, err) 49 | uids[i] = uid 50 | }) 51 | 52 | slices.Sort(uids) 53 | 54 | for i := 0; i < UIDCount-1; i++ { 55 | assert.Less(t, uids[i], uids[i+1]) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /reporter/utils.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func MessageWithContext(ctx context.Context, message string, context Context) { 10 | reporter, ok := GetReporterFromContext(ctx) 11 | if !ok { 12 | return 13 | } 14 | 15 | if err := reporter.ReportMessageWithContext(message, context); err != nil { 16 | logrus.WithError(err).Error("Failed to report message") 17 | } 18 | } 19 | 20 | func ExceptionWithContext(ctx context.Context, message string, context Context) { 21 | reporter, ok := GetReporterFromContext(ctx) 22 | if !ok { 23 | return 24 | } 25 | 26 | if err := reporter.ReportExceptionWithContext(message, context); err != nil { 27 | logrus.WithError(err).Error("Failed to report message") 28 | } 29 | } 30 | 31 | func Exception(ctx context.Context, info any) { 32 | reporter, ok := GetReporterFromContext(ctx) 33 | if !ok { 34 | return 35 | } 36 | 37 | if err := reporter.ReportException(info); err != nil { 38 | logrus.WithError(err).Error("Failed to report message") 39 | } 40 | } 41 | 42 | func Message(ctx context.Context, message string) { 43 | reporter, ok := GetReporterFromContext(ctx) 44 | if !ok { 45 | return 46 | } 47 | 48 | if err := reporter.ReportMessage(message); err != nil { 49 | logrus.WithError(err).Error("Failed to report message") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/README.md: -------------------------------------------------------------------------------- 1 | # Gluon Bench - IMAP benchmarks 2 | 3 | Gluon bench provides a collection of benchmarks that operate either at the IMAP client level or directly on Gluon 4 | itself (e.g: sync). 5 | 6 | All IMAP command related benchmarks can be run against a local gluon server which will be started with the benchmark or 7 | an externally running IMAP server. 8 | 9 | If running against a local server, it's possible to record the execution times of every individual command. 10 | 11 | Finally, it is also possible to produce a JSON report rather than printing to the console. 12 | 13 | 14 | ## Building 15 | 16 | ```bash 17 | # In benchmarks/gluon_bench 18 | go build main.go -o gluon_bench 19 | ``` 20 | 21 | ## Running Gluon Bench 22 | 23 | To run Gluon Bench specify a set of options followed by a set of benchmarks you wish to run: 24 | 25 | ```bash 26 | gluon_bench -verbose -parallel-client=4 fetch append 27 | ``` 28 | 29 | Please consult the output of `gluon_bench -h` for all available options/modifiers and benchmarks. 30 | 31 | 32 | ## Integrating Gluon Bench in other projects 33 | 34 | When integrating Gluon Bench in other projects which may contain other gluon connectors: 35 | 36 | * Register your connector with `utils.RegisterConnector()` 37 | * Specify the connector with the option `-connector=<...>` 38 | * In your `main` call `benchmark.RunMain()` -------------------------------------------------------------------------------- /imap/seqset.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/bradenaw/juniper/xslices" 9 | "golang.org/x/exp/slices" 10 | ) 11 | 12 | type SeqVal struct { 13 | Begin, End SeqID 14 | } 15 | 16 | func (seqval SeqVal) canCombine(val SeqID) bool { 17 | return val == SeqID(uint32(seqval.End)+1) 18 | } 19 | 20 | func (seqval SeqVal) String() string { 21 | if seqval.End > seqval.Begin { 22 | return fmt.Sprintf("%v:%v", seqval.Begin, seqval.End) 23 | } 24 | 25 | return strconv.FormatUint(uint64(seqval.End), 10) 26 | } 27 | 28 | type SeqSet []SeqVal 29 | 30 | func NewSeqSetFromUID(set []UID) SeqSet { 31 | return NewSeqSet(xslices.Map(set, func(t UID) SeqID { 32 | return SeqID(t) 33 | })) 34 | } 35 | 36 | func NewSeqSet(set []SeqID) SeqSet { 37 | slices.Sort(set) 38 | 39 | var res SeqSet 40 | 41 | for _, val := range set { 42 | if n := len(res); n > 0 { 43 | if res[n-1].canCombine(val) { 44 | res[n-1].End = val 45 | } else { 46 | res = append(res, SeqVal{Begin: val, End: val}) 47 | } 48 | } else { 49 | res = append(res, SeqVal{Begin: val, End: val}) 50 | } 51 | } 52 | 53 | return res 54 | } 55 | 56 | func (set SeqSet) String() string { 57 | var res []string 58 | 59 | for _, val := range set { 60 | res = append(res, val.String()) 61 | } 62 | 63 | return strings.Join(res, ",") 64 | } 65 | -------------------------------------------------------------------------------- /imap/strong_types.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type MailboxID string 11 | 12 | type MessageID string 13 | 14 | func (l MailboxID) ShortID() string { 15 | return ShortID(string(l)) 16 | } 17 | 18 | func (m MessageID) ShortID() string { 19 | return ShortID(string(m)) 20 | } 21 | 22 | type InternalMessageID struct { 23 | uuid.UUID 24 | } 25 | 26 | type InternalMailboxID uint64 27 | 28 | func (i InternalMailboxID) ShortID() string { 29 | return strconv.FormatUint(uint64(i), 10) 30 | } 31 | 32 | func (i InternalMessageID) ShortID() string { 33 | return ShortID(i.String()) 34 | } 35 | 36 | func (i InternalMailboxID) String() string { 37 | return strconv.FormatUint(uint64(i), 10) 38 | } 39 | 40 | func (i InternalMessageID) String() string { 41 | return i.UUID.String() 42 | } 43 | 44 | func NewInternalMessageID() InternalMessageID { 45 | return InternalMessageID{UUID: uuid.New()} 46 | } 47 | 48 | func InternalMessageIDFromString(id string) (InternalMessageID, error) { 49 | num, err := uuid.Parse(id) 50 | if err != nil { 51 | return InternalMessageID{}, fmt.Errorf("invalid message id:%w", err) 52 | } 53 | 54 | return InternalMessageID{UUID: num}, nil 55 | } 56 | 57 | type UID uint32 58 | 59 | func (u UID) Add(v uint32) UID { 60 | return UID(uint32(u) + v) 61 | } 62 | 63 | type SeqID uint32 64 | -------------------------------------------------------------------------------- /internal/session/errors.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | 8 | "github.com/ProtonMail/gluon/connector" 9 | "github.com/ProtonMail/gluon/internal/state" 10 | "github.com/ProtonMail/gluon/rfc822" 11 | ) 12 | 13 | var ( 14 | ErrCreateInbox = errors.New("cannot create INBOX") 15 | ErrDeleteInbox = errors.New("cannot delete INBOX") 16 | ErrReadOnly = errors.New("the mailbox is read-only") 17 | 18 | ErrTLSUnavailable = errors.New("TLS is unavailable") 19 | ErrNotAuthenticated = errors.New("session is not authenticated") 20 | ErrAlreadyAuthenticated = errors.New("session is already authenticated") 21 | 22 | ErrNotImplemented = errors.New("not implemented") 23 | ) 24 | 25 | func shouldReportIMAPCommandError(err error) bool { 26 | var netErr *net.OpError 27 | 28 | switch { 29 | case errors.Is(err, ErrCreateInbox) || errors.Is(err, ErrDeleteInbox) || errors.Is(err, ErrReadOnly): 30 | return false 31 | case state.IsStateError(err): 32 | return false 33 | case errors.Is(err, connector.ErrOperationNotAllowed): 34 | return false 35 | case errors.Is(err, context.Canceled): 36 | return false 37 | case errors.As(err, &netErr): 38 | return false 39 | case errors.Is(err, rfc822.ErrNoSuchPart): 40 | return false 41 | case errors.Is(err, state.ErrKnownRecoveredMessage): 42 | return false 43 | } 44 | 45 | return true 46 | } 47 | -------------------------------------------------------------------------------- /internal/backend/connector_state.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/connector" 7 | "github.com/ProtonMail/gluon/db" 8 | "github.com/ProtonMail/gluon/internal/state" 9 | ) 10 | 11 | type DBIMAPState struct { 12 | user *user 13 | client db.Client 14 | } 15 | 16 | func NewDBIMAPState(client db.Client, user *user) *DBIMAPState { 17 | return &DBIMAPState{client: client, user: user} 18 | } 19 | 20 | func (d *DBIMAPState) Read(ctx context.Context, f func(context.Context, connector.IMAPStateRead) error) error { 21 | return d.client.Read(ctx, func(ctx context.Context, only db.ReadOnly) error { 22 | rd := DBIMAPStateRead{rd: only, delimiter: d.user.delimiter} 23 | 24 | return f(ctx, &rd) 25 | }) 26 | } 27 | 28 | func (d *DBIMAPState) Write(ctx context.Context, f func(context.Context, connector.IMAPStateWrite) error) error { 29 | var stateUpdates []state.Update 30 | 31 | err := d.client.Write(ctx, func(ctx context.Context, tx db.Transaction) error { 32 | wr := DBIMAPStateWrite{ 33 | DBIMAPStateRead: DBIMAPStateRead{rd: tx, delimiter: d.user.delimiter}, 34 | tx: tx, 35 | user: d.user, 36 | } 37 | 38 | err := f(ctx, &wr) 39 | 40 | stateUpdates = wr.stateUpdates 41 | 42 | return err 43 | }) 44 | 45 | if err == nil { 46 | d.user.queueStateUpdate(stateUpdates...) 47 | } 48 | 49 | return err 50 | } 51 | -------------------------------------------------------------------------------- /rfcvalidation/validation_test.go: -------------------------------------------------------------------------------- 1 | package rfcvalidation 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestValidateMessageHeaderFields_RequiredFieldsPass(t *testing.T) { 10 | const literal = `From: Foo@bar.com 11 | Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST) 12 | ` 13 | 14 | require.NoError(t, ValidateMessageHeaderFields([]byte(literal))) 15 | } 16 | 17 | func TestValidateMessageHeaderFields_ErrOnMissingFrom(t *testing.T) { 18 | const literal = `Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST) 19 | ` 20 | 21 | require.Error(t, ValidateMessageHeaderFields([]byte(literal))) 22 | } 23 | 24 | func TestValidateMessageHeaderFields_ErrOnMissingDate(t *testing.T) { 25 | const literal = `From: Foo@bar.com 26 | ` 27 | 28 | require.Error(t, ValidateMessageHeaderFields([]byte(literal))) 29 | } 30 | 31 | func TestValidateMessageHeaderFields_AllowSingleFromWithDifferentSender(t *testing.T) { 32 | const literal = `From: Foo@bar.com 33 | Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST) 34 | Sender: Bar@bar.com 35 | ` 36 | 37 | require.NoError(t, ValidateMessageHeaderFields([]byte(literal))) 38 | } 39 | 40 | func TestValidateMessageHeaderFields_ErrOnMultipleFromAndNoSender(t *testing.T) { 41 | const literal = `From: Foo@bar.com, Bar@bar.com 42 | Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST) 43 | ` 44 | 45 | require.Error(t, ValidateMessageHeaderFields([]byte(literal))) 46 | } 47 | -------------------------------------------------------------------------------- /tests/counts_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/ProtonMail/gluon/events" 8 | "github.com/ProtonMail/gluon/internal/ids" 9 | goimap "github.com/emersion/go-imap" 10 | "github.com/emersion/go-imap/client" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestCounts(t *testing.T) { 15 | dir := t.TempDir() 16 | 17 | runOneToOneTestClientWithAuth(t, defaultServerOptions(t, withDataDir(dir)), func(client *client.Client, s *testSession) { 18 | for _, count := range getEvent[events.UserAdded](s.eventCh).Counts { 19 | require.Equal(t, 0, count) 20 | } 21 | 22 | for i := 0; i < 10; i++ { 23 | require.NoError(t, doAppendWithClientFromFile(t, client, "INBOX", "testdata/afternoon-meeting.eml", time.Now(), goimap.SeenFlag)) 24 | } 25 | }) 26 | 27 | runOneToOneTestClientWithAuth(t, defaultServerOptions(t, withDataDir(dir)), func(_ *client.Client, s *testSession) { 28 | for mbox, count := range getEvent[events.UserAdded](s.eventCh).Counts { 29 | if mbox == ids.GluonInternalRecoveryMailboxRemoteID { 30 | require.Equal(t, 0, count) 31 | } else { 32 | require.Equal(t, 10, count) 33 | } 34 | } 35 | }) 36 | } 37 | 38 | func getEvent[T any](eventCh <-chan events.Event) T { 39 | for event := range eventCh { 40 | if event, ok := event.(T); ok { 41 | return event 42 | } 43 | } 44 | 45 | panic("no event") 46 | } 47 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func ReadLinesFromFile(path string) ([]string, error) { 12 | readFile, err := os.Open(path) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | defer readFile.Close() 18 | 19 | fileScanner := bufio.NewScanner(readFile) 20 | fileScanner.Split(bufio.ScanLines) 21 | 22 | lines := make([]string, 0, 16) 23 | 24 | for fileScanner.Scan() { 25 | lines = append(lines, fileScanner.Text()) 26 | } 27 | 28 | return lines, nil 29 | } 30 | 31 | func LoadFilesFromDirectory(path string, filter func(string, fs.FileInfo) bool) ([]string, error) { 32 | var files []string 33 | 34 | if err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error { 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if !filter(path, info) { 40 | return nil 41 | } 42 | 43 | bytes, err := os.ReadFile(path) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | files = append(files, string(bytes)) 49 | 50 | return nil 51 | }); err != nil { 52 | return nil, err 53 | } 54 | 55 | return files, nil 56 | } 57 | 58 | func LoadEMLFilesFromDirectory(path string) ([]string, error) { 59 | return LoadFilesFromDirectory(path, func(s string, info fs.FileInfo) bool { 60 | return !info.IsDir() && strings.HasSuffix(s, ".eml") 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /benchmarks/gluon_bench/store_benchmarks/create.go: -------------------------------------------------------------------------------- 1 | package store_benchmarks 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | 7 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/benchmark" 8 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/flags" 9 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/reporter" 10 | "github.com/ProtonMail/gluon/benchmarks/gluon_bench/timing" 11 | "github.com/ProtonMail/gluon/imap" 12 | "github.com/ProtonMail/gluon/store" 13 | ) 14 | 15 | type Create struct{} 16 | 17 | func (*Create) Name() string { 18 | return "store-create" 19 | } 20 | 21 | func (*Create) Setup(ctx context.Context, store store.Store) error { 22 | return nil 23 | } 24 | 25 | func (*Create) TearDown(ctx context.Context, store store.Store) error { 26 | return nil 27 | } 28 | 29 | func (*Create) Run(ctx context.Context, st store.Store) (*reporter.BenchmarkRun, error) { 30 | return RunStoreWorkers(ctx, st, func(ctx context.Context, s store.Store, dc *timing.Collector, u uint) error { 31 | data := make([]byte, *flags.StoreItemSize) 32 | 33 | for i := uint(0); i < *flags.StoreItemCount; i++ { 34 | dc.Start() 35 | err := s.Set(imap.NewInternalMessageID(), bytes.NewReader(data)) 36 | dc.Stop() 37 | 38 | if err != nil { 39 | return err 40 | } 41 | 42 | } 43 | 44 | return nil 45 | }), nil 46 | } 47 | 48 | func init() { 49 | benchmark.RegisterBenchmark(NewStoreBenchmarkRunner(&Create{})) 50 | } 51 | -------------------------------------------------------------------------------- /db/client.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | 7 | "github.com/ProtonMail/gluon/imap" 8 | ) 9 | 10 | const ChunkLimit = 1000 11 | 12 | type Client interface { 13 | Init(ctx context.Context, generator imap.UIDValidityGenerator) error 14 | Read(ctx context.Context, op func(context.Context, ReadOnly) error) error 15 | Write(ctx context.Context, op func(context.Context, Transaction) error) error 16 | Close() error 17 | } 18 | 19 | type ClientInterface interface { 20 | New(path string, userID string) (Client, bool, error) 21 | Delete(path string, userID string) error 22 | } 23 | 24 | func GetDeferredDeleteDBPath(dir string) string { 25 | return filepath.Join(dir, "deferred_delete") 26 | } 27 | 28 | func ClientReadType[T any](ctx context.Context, c Client, op func(context.Context, ReadOnly) (T, error)) (T, error) { 29 | var result T 30 | 31 | err := c.Read(ctx, func(ctx context.Context, read ReadOnly) error { 32 | var err error 33 | 34 | result, err = op(ctx, read) 35 | 36 | return err 37 | }) 38 | 39 | return result, err 40 | } 41 | 42 | func ClientWriteType[T any](ctx context.Context, c Client, op func(context.Context, Transaction) (T, error)) (T, error) { 43 | var result T 44 | 45 | err := c.Write(ctx, func(ctx context.Context, t Transaction) error { 46 | var err error 47 | 48 | result, err = op(ctx, t) 49 | 50 | return err 51 | }) 52 | 53 | return result, err 54 | } 55 | -------------------------------------------------------------------------------- /internal/db_impl/sqlite3/types.go: -------------------------------------------------------------------------------- 1 | package sqlite3 2 | 3 | import ( 4 | "github.com/ProtonMail/gluon/db" 5 | "github.com/ProtonMail/gluon/internal/db_impl/sqlite3/utils" 6 | ) 7 | 8 | func ScanMailbox(scanner utils.RowScanner) (*db.Mailbox, error) { 9 | mbox := new(db.Mailbox) 10 | 11 | if err := scanner.Scan(&mbox.ID, &mbox.RemoteID, &mbox.Name, &mbox.UIDValidity, &mbox.Subscribed); err != nil { 12 | return nil, err 13 | } 14 | 15 | return mbox, nil 16 | } 17 | 18 | func ScanMailboxWithAttr(scanner utils.RowScanner) (*db.MailboxWithAttr, error) { 19 | mbox := new(db.MailboxWithAttr) 20 | 21 | if err := scanner.Scan(&mbox.ID, &mbox.RemoteID, &mbox.Name, &mbox.UIDValidity, &mbox.Subscribed); err != nil { 22 | return nil, err 23 | } 24 | 25 | return mbox, nil 26 | } 27 | 28 | func ScanMessage(scanner utils.RowScanner) (*db.Message, error) { 29 | msg := new(db.Message) 30 | 31 | if err := scanner.Scan(&msg.ID, &msg.RemoteID, &msg.Date, &msg.Size, &msg.Body, &msg.BodyStructure, &msg.Envelope, &msg.Deleted); err != nil { 32 | return nil, err 33 | } 34 | 35 | return msg, nil 36 | } 37 | 38 | func ScanMessageWithFlags(scanner utils.RowScanner) (*db.MessageWithFlags, error) { 39 | msg := new(db.MessageWithFlags) 40 | 41 | if err := scanner.Scan(&msg.ID, &msg.RemoteID, &msg.Date, &msg.Size, &msg.Body, &msg.BodyStructure, &msg.Envelope, &msg.Deleted); err != nil { 42 | return nil, err 43 | } 44 | 45 | return msg, nil 46 | } 47 | -------------------------------------------------------------------------------- /watcher/watcher_test.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ProtonMail/gluon/async" 7 | "github.com/ProtonMail/gluon/events" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestWatcher(t *testing.T) { 12 | watcher := New[events.Event]( 13 | async.NoopPanicHandler{}, 14 | events.ListenerAdded{}, 15 | events.ListenerRemoved{}, 16 | ) 17 | 18 | // The watcher is watching the correct types. 19 | require.True(t, watcher.IsWatching(events.ListenerAdded{})) 20 | require.True(t, watcher.IsWatching(events.ListenerRemoved{})) 21 | 22 | // The watcher is not watching the incorrect types. 23 | require.False(t, watcher.IsWatching(events.Login{})) 24 | require.False(t, watcher.IsWatching(events.Select{})) 25 | 26 | // Get a channel to read from the watcher. 27 | resCh := watcher.GetChannel() 28 | 29 | // Send some events to the watcher. 30 | require.True(t, watcher.Send(events.ListenerAdded{})) 31 | require.True(t, watcher.Send(events.ListenerRemoved{})) 32 | 33 | // Check we can read the events off the channel. 34 | require.Equal(t, events.ListenerAdded{}, <-resCh) 35 | require.Equal(t, events.ListenerRemoved{}, <-resCh) 36 | 37 | // Close the watcher. 38 | watcher.Close() 39 | 40 | // Sending more events after the watcher is closed should return false. 41 | require.False(t, watcher.Send(events.ListenerAdded{})) 42 | require.False(t, watcher.Send(events.ListenerRemoved{})) 43 | } 44 | -------------------------------------------------------------------------------- /internal/session/handle_id.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ProtonMail/gluon/events" 7 | "github.com/ProtonMail/gluon/imap" 8 | "github.com/ProtonMail/gluon/imap/command" 9 | "github.com/ProtonMail/gluon/internal/response" 10 | "github.com/ProtonMail/gluon/profiling" 11 | ) 12 | 13 | func (s *Session) handleIDGet(ctx context.Context, tag string, ch chan response.Response) error { 14 | profiling.Start(ctx, profiling.CmdTypeID) 15 | defer profiling.Stop(ctx, profiling.CmdTypeID) 16 | 17 | ch <- response.ID(imap.NewIMAPIDFromVersionInfo(s.version)) 18 | 19 | ch <- response.Ok(tag).WithMessage("ID") 20 | 21 | return nil 22 | } 23 | 24 | func (s *Session) handleIDSet(ctx context.Context, tag string, cmd *command.IDSet, ch chan response.Response) error { 25 | profiling.Start(ctx, profiling.CmdTypeID) 26 | defer profiling.Stop(ctx, profiling.CmdTypeID) 27 | 28 | // Update session IMAP ID. 29 | s.imapID = imap.NewIMAPIDFromKeyMap(cmd.Values) 30 | 31 | // If logged in and a mailbox has been selected, set the IMAP ID in the state's metadata. 32 | if s.state != nil { 33 | s.state.SetConnMetadataKeyValue(imap.IMAPIDConnMetadataKey, s.imapID) 34 | } 35 | 36 | ch <- response.ID(imap.NewIMAPIDFromVersionInfo(s.version)) 37 | 38 | ch <- response.Ok(tag).WithMessage("ID") 39 | 40 | s.eventCh <- events.IMAPID{ 41 | SessionID: s.sessionID, 42 | IMAPID: s.imapID, 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /rfc822/mime.go: -------------------------------------------------------------------------------- 1 | package rfc822 2 | 3 | import ( 4 | "mime" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | // ParseMediaType parses a MIME media type. 10 | var ParseMediaType = mime.ParseMediaType 11 | 12 | type MIMEType string 13 | 14 | const ( 15 | TextPlain MIMEType = "text/plain" 16 | TextHTML MIMEType = "text/html" 17 | MultipartMixed MIMEType = "multipart/mixed" 18 | MultipartRelated MIMEType = "multipart/related" 19 | MessageRFC822 MIMEType = "message/rfc822" 20 | ) 21 | 22 | func (mimeType MIMEType) IsMultiPart() bool { 23 | return strings.HasPrefix(string(mimeType), "multipart/") 24 | } 25 | 26 | func (mimeType MIMEType) Type() string { 27 | if split := strings.SplitN(string(mimeType), "/", 2); len(split) == 2 { 28 | return split[0] 29 | } 30 | 31 | return "" 32 | } 33 | 34 | func (mimeType MIMEType) SubType() string { 35 | if split := strings.SplitN(string(mimeType), "/", 2); len(split) == 2 { 36 | return split[1] 37 | } 38 | 39 | return "" 40 | } 41 | 42 | func ParseMIMEType(val string) (MIMEType, map[string]string, error) { 43 | if val == "" { 44 | val = string(TextPlain) 45 | } 46 | 47 | sanitized := strings.Map(func(r rune) rune { 48 | if r > unicode.MaxASCII { 49 | return -1 50 | } 51 | return r 52 | }, val) 53 | 54 | mimeType, mimeParams, err := ParseMediaType(sanitized) 55 | if err != nil { 56 | return "", nil, err 57 | } 58 | 59 | return MIMEType(mimeType), mimeParams, nil 60 | } 61 | --------------------------------------------------------------------------------