diff --git a/docker-compose.yaml b/docker-compose.yaml index 06417e1..66c8181 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -92,7 +92,7 @@ services: build: { context: ./services/protocols } restart: unless-stopped logging: *default-logging - ports: ["8080:8080", "25:25"] + ports: ["8080:8080", "25:25", "143:143"] environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost} diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index d55c5cb..4664a5a 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" + "github.com/emersion/go-imap/v2/imapserver" gosmtp "github.com/emersion/go-smtp" "lambdamail/protocols/internal/application/usecase" @@ -15,6 +16,7 @@ import ( "lambdamail/protocols/internal/infrastructure/diskstorage" "lambdamail/protocols/internal/infrastructure/postgres" tlsprovider "lambdamail/protocols/internal/infrastructure/tls" + imappresentation "lambdamail/protocols/internal/presentation/imap" smtppresentation "lambdamail/protocols/internal/presentation/smtp" ) @@ -76,6 +78,28 @@ func main() { } }() + authRepo := postgres.NewAuthRepository(pool) + imapFolders := postgres.NewImapFolderRepository(pool) + messageQuery := postgres.NewMessageQueryRepository(pool) + flagRepo := postgres.NewFlagRepository(pool) + blobReader := diskstorage.NewLocalDiskBlobReader(pool) + imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader) + + imapServer := imapserver.New(&imapserver.Options{ + NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return imappresentation.NewSession(c, imapUseCase) + }, + TLSConfig: &tls.Config{GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS12}, + InsecureAuth: false, + }) + + go func() { + log.Printf("lambdamail-protocols IMAP listening on :143") + if err := imapServer.ListenAndServe(":143"); err != nil { + log.Fatalf("imap serve: %v", err) + } + }() + handler := health.Handler(func() error { return pool.Ping(ctx) }) addr := ":8080" log.Printf("lambdamail-protocols health listening on %s", addr) diff --git a/services/protocols/go.mod b/services/protocols/go.mod index 75d54b6..d1f046c 100644 --- a/services/protocols/go.mod +++ b/services/protocols/go.mod @@ -9,6 +9,9 @@ require ( ) require ( + github.com/alexedwards/argon2id v1.0.0 // indirect + github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect + github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/fergusstrange/embedded-postgres v1.30.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -16,6 +19,8 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.10.9 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + golang.org/x/crypto v0.14.0 // indirect golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/services/protocols/go.sum b/services/protocols/go.sum index 2f0a0f0..019f9c8 100644 --- a/services/protocols/go.sum +++ b/services/protocols/go.sum @@ -1,6 +1,12 @@ +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= @@ -28,10 +34,51 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/services/protocols/internal/application/port/auth_repository.go b/services/protocols/internal/application/port/auth_repository.go new file mode 100644 index 0000000..8962e94 --- /dev/null +++ b/services/protocols/internal/application/port/auth_repository.go @@ -0,0 +1,20 @@ +package port + +import ( + "context" + + "github.com/google/uuid" +) + +// MailboxAuth is the subset of mailbox data needed to verify a login. +type MailboxAuth struct { + ID uuid.UUID + PasswordHash string +} + +// AuthRepository resolves a login address to its stored credential. +type AuthRepository interface { + // FindByAddress returns nil, nil (not an error) when no active mailbox + // with an active domain matches address. + FindByAddress(ctx context.Context, address string) (*MailboxAuth, error) +} diff --git a/services/protocols/internal/application/port/blob_reader.go b/services/protocols/internal/application/port/blob_reader.go new file mode 100644 index 0000000..721820f --- /dev/null +++ b/services/protocols/internal/application/port/blob_reader.go @@ -0,0 +1,12 @@ +package port + +import ( + "context" + + "github.com/google/uuid" +) + +// BlobReader reads a previously stored message's full raw bytes. +type BlobReader interface { + ReadByID(ctx context.Context, blobID uuid.UUID) ([]byte, error) +} diff --git a/services/protocols/internal/application/port/flag_repository.go b/services/protocols/internal/application/port/flag_repository.go new file mode 100644 index 0000000..9a84e1f --- /dev/null +++ b/services/protocols/internal/application/port/flag_repository.go @@ -0,0 +1,21 @@ +package port + +import "context" + +// FlagOp mirrors imap.StoreFlagsOp without importing the imap package here +// (this port stays framework-free). +type FlagOp int + +const ( + FlagOpAdd FlagOp = iota + FlagOpDel + FlagOpSet +) + +// FlagRepository mutates a message's IMAP flags (STORE). +type FlagRepository interface { + // SetFlags applies op with flags to the message identified by + // (folderID, uid). Add/Del insert/delete individual message_flags rows; + // Set replaces the message's full flag set in one transaction. + SetFlags(ctx context.Context, folderID string, uid uint32, op FlagOp, flags []string) error +} diff --git a/services/protocols/internal/application/port/imap_folder_repository.go b/services/protocols/internal/application/port/imap_folder_repository.go new file mode 100644 index 0000000..38813f1 --- /dev/null +++ b/services/protocols/internal/application/port/imap_folder_repository.go @@ -0,0 +1,20 @@ +package port + +import "context" + +// ImapFolderRecord is the IMAP-relevant subset of a folder row. +type ImapFolderRecord struct { + ID string + Name string + UIDNext uint32 + UIDValidity uint32 + NumMessages uint32 +} + +// ImapFolderRepository resolves a mailbox's folders for LIST and SELECT. +type ImapFolderRepository interface { + ListFolders(ctx context.Context, mailboxID string) ([]ImapFolderRecord, error) + // FindByName returns nil, nil (not an error) when no folder with that + // name exists for the mailbox. + FindByName(ctx context.Context, mailboxID, name string) (*ImapFolderRecord, error) +} diff --git a/services/protocols/internal/application/port/message_query_repository.go b/services/protocols/internal/application/port/message_query_repository.go new file mode 100644 index 0000000..598fe2f --- /dev/null +++ b/services/protocols/internal/application/port/message_query_repository.go @@ -0,0 +1,26 @@ +package port + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// MessageRecord is one message's IMAP-relevant data. +type MessageRecord struct { + UID uint32 + BlobID uuid.UUID + SizeBytes int64 + ReceivedAt time.Time + Flags []string +} + +// MessageQueryRepository lists a folder's messages for FETCH. +type MessageQueryRepository interface { + // ListMessages returns every message in folderID, ordered by UID + // ascending. The caller derives IMAP sequence numbers from this order + // (row N is sequence number N+1) - safe because this sub-project never + // expunges a message, so sequence numbers never need remapping. + ListMessages(ctx context.Context, folderID string) ([]MessageRecord, error) +} diff --git a/services/protocols/internal/application/usecase/imap_session.go b/services/protocols/internal/application/usecase/imap_session.go new file mode 100644 index 0000000..c03e33c --- /dev/null +++ b/services/protocols/internal/application/usecase/imap_session.go @@ -0,0 +1,80 @@ +package usecase + +import ( + "context" + "errors" + + "github.com/alexedwards/argon2id" + "github.com/google/uuid" + + "lambdamail/protocols/internal/application/port" +) + +// ErrAuthFailed is returned by Login for any credential problem - unknown +// address, wrong password, or (once AuthRepository filters it) an inactive +// mailbox/domain. Deliberately the same error in every case: distinguishing +// them would let a client enumerate valid addresses (PLAN.md section 14.6). +var ErrAuthFailed = errors.New("imap session: authentication failed") + +// ErrNoSuchFolder is returned by SelectFolder when the named folder doesn't exist. +var ErrNoSuchFolder = errors.New("imap session: no such folder") + +// ImapSessionUseCase implements the read/auth half of PLAN.md section 3's +// HandleImapCommandUseCase (this sub-project's scope: LOGIN, LIST, SELECT, +// FETCH, STORE; SEARCH/EXPUNGE/IDLE/MOVE land in later sub-projects). +type ImapSessionUseCase struct { + auth port.AuthRepository + folders port.ImapFolderRepository + messages port.MessageQueryRepository + flags port.FlagRepository + blobs port.BlobReader +} + +func NewImapSessionUseCase(auth port.AuthRepository, folders port.ImapFolderRepository, messages port.MessageQueryRepository, flags port.FlagRepository, blobs port.BlobReader) *ImapSessionUseCase { + return &ImapSessionUseCase{auth: auth, folders: folders, messages: messages, flags: flags, blobs: blobs} +} + +func (uc *ImapSessionUseCase) Login(ctx context.Context, address, password string) (string, error) { + rec, err := uc.auth.FindByAddress(ctx, address) + if err != nil { + return "", err + } + if rec == nil { + return "", ErrAuthFailed + } + match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) + if err != nil { + return "", err + } + if !match { + return "", ErrAuthFailed + } + return rec.ID.String(), nil +} + +func (uc *ImapSessionUseCase) ListFolders(ctx context.Context, mailboxID string) ([]port.ImapFolderRecord, error) { + return uc.folders.ListFolders(ctx, mailboxID) +} + +func (uc *ImapSessionUseCase) SelectFolder(ctx context.Context, mailboxID, name string) (*port.ImapFolderRecord, error) { + rec, err := uc.folders.FindByName(ctx, mailboxID, name) + if err != nil { + return nil, err + } + if rec == nil { + return nil, ErrNoSuchFolder + } + return rec, nil +} + +func (uc *ImapSessionUseCase) FetchMessages(ctx context.Context, folderID string) ([]port.MessageRecord, error) { + return uc.messages.ListMessages(ctx, folderID) +} + +func (uc *ImapSessionUseCase) ReadBlob(ctx context.Context, blobID uuid.UUID) ([]byte, error) { + return uc.blobs.ReadByID(ctx, blobID) +} + +func (uc *ImapSessionUseCase) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string) error { + return uc.flags.SetFlags(ctx, folderID, uid, op, flags) +} diff --git a/services/protocols/internal/application/usecase/imap_session_test.go b/services/protocols/internal/application/usecase/imap_session_test.go new file mode 100644 index 0000000..be73f85 --- /dev/null +++ b/services/protocols/internal/application/usecase/imap_session_test.go @@ -0,0 +1,111 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "lambdamail/protocols/internal/application/port" +) + +const ( + testPassword = "correct horse battery staple" + testPasswordHash = "$argon2id$v=19$m=65536,t=1,p=12$I88aUc0rZHHX+Bt+jX+4Ag$3jKTtg1khJjFo9az4iPvFLc3eKY8WKSFy/1YMXlKvSU" +) + +type fakeAuthRepository struct { + byAddress map[string]port.MailboxAuth +} + +func (f *fakeAuthRepository) FindByAddress(_ context.Context, address string) (*port.MailboxAuth, error) { + rec, ok := f.byAddress[address] + if !ok { + return nil, nil + } + return &rec, nil +} + +func TestImapSessionUseCase_Login_SucceedsForCorrectPassword(t *testing.T) { + mailboxID := uuid.New() + // PHC hash for password "correct horse battery staple", generated with argon2id.CreateHash. + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + uc := NewImapSessionUseCase(auth, nil, nil, nil, nil) + + gotID, err := uc.Login(context.Background(), "user@example.test", testPassword) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotID != mailboxID.String() { + t.Errorf("mailboxID = %q, want %q", gotID, mailboxID.String()) + } +} + +func TestImapSessionUseCase_Login_FailsForWrongPassword(t *testing.T) { + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: uuid.New(), PasswordHash: testPasswordHash}, + }} + uc := NewImapSessionUseCase(auth, nil, nil, nil, nil) + + _, err := uc.Login(context.Background(), "user@example.test", "wrong-password") + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("error = %v, want ErrAuthFailed", err) + } +} + +func TestImapSessionUseCase_Login_FailsForUnknownAddress(t *testing.T) { + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{}} + uc := NewImapSessionUseCase(auth, nil, nil, nil, nil) + + _, err := uc.Login(context.Background(), "nobody@example.test", "anything") + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("error = %v, want ErrAuthFailed (never a distinct \"no such user\" error - PLAN.md section 14.6)", err) + } +} + +type fakeImapFolderRepository struct { + byName map[string]port.ImapFolderRecord +} + +func (f *fakeImapFolderRepository) ListFolders(_ context.Context, _ string) ([]port.ImapFolderRecord, error) { + var out []port.ImapFolderRecord + for _, rec := range f.byName { + out = append(out, rec) + } + return out, nil +} + +func (f *fakeImapFolderRepository) FindByName(_ context.Context, _ string, name string) (*port.ImapFolderRecord, error) { + rec, ok := f.byName[name] + if !ok { + return nil, nil + } + return &rec, nil +} + +func TestImapSessionUseCase_SelectFolder_ReturnsRecordForExistingFolder(t *testing.T) { + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{ + "INBOX": {ID: "folder-1", Name: "INBOX", UIDNext: 3, UIDValidity: 1000, NumMessages: 2}, + }} + uc := NewImapSessionUseCase(nil, folders, nil, nil, nil) + + rec, err := uc.SelectFolder(context.Background(), "mailbox-1", "INBOX") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.ID != "folder-1" || rec.UIDNext != 3 { + t.Errorf("rec = %+v, want folder-1 with UIDNext 3", rec) + } +} + +func TestImapSessionUseCase_SelectFolder_ReturnsErrNoSuchFolderWhenMissing(t *testing.T) { + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{}} + uc := NewImapSessionUseCase(nil, folders, nil, nil, nil) + + _, err := uc.SelectFolder(context.Background(), "mailbox-1", "Nonexistent") + if !errors.Is(err, ErrNoSuchFolder) { + t.Fatalf("error = %v, want ErrNoSuchFolder", err) + } +} diff --git a/services/protocols/internal/e2e/imap_core_e2e_test.go b/services/protocols/internal/e2e/imap_core_e2e_test.go new file mode 100644 index 0000000..34d116f --- /dev/null +++ b/services/protocols/internal/e2e/imap_core_e2e_test.go @@ -0,0 +1,199 @@ +package e2e + +import ( + "bytes" + "context" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/alexedwards/argon2id" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-imap/v2/imapserver" + + appusecase "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/application/port" + "lambdamail/protocols/internal/infrastructure/diskstorage" + "lambdamail/protocols/internal/infrastructure/postgres" + imappresentation "lambdamail/protocols/internal/presentation/imap" +) + +func TestImapCoreEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54330). // distinct from the SMTP E2E test's 54329, so both can run concurrently + RuntimePath(runtimeDir). + StartTimeout(120 * time.Second)) + if err := pg.Start(); err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + defer pg.Stop() + + dbURL := "postgres://postgres:postgres@localhost:54330/postgres?sslmode=disable" + pool, err := postgres.NewPool(ctx, dbURL) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + root := repoRoot(t) + sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql")) + if err != nil { + t.Fatalf("read migration: %v", err) + } + if _, err := pool.Exec(ctx, string(sql)); err != nil { + t.Fatalf("apply migration: %v", err) + } + + // --- Seed a real mailbox with a known password ------------------------- + password := "correct horse battery staple" + hash, err := argon2id.CreateHash(password, argon2id.DefaultParams) + if err != nil { + t.Fatalf("hash password: %v", err) + } + domainID := "00000000-0000-0000-0000-000000000001" + mailboxID := "00000000-0000-0000-0000-000000000002" + folderID := "00000000-0000-0000-0000-000000000003" + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'example.test', 'example.test')`, domainID); err != nil { + t.Fatalf("seed domain: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'postmaster', 'postmaster@example.test', $3)`, mailboxID, domainID, hash); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, folderID, mailboxID); err != nil { + t.Fatalf("seed folder: %v", err) + } + + // --- Seed a real message via F1's inbound use case (not a raw INSERT - -- + // --- this proves the IMAP side reads what the SMTP side really wrote) -- + spool := t.TempDir() + inboundUC := appusecase.NewProcessInboundEmailUseCase( + postgres.NewMailboxRepository(pool), + diskstorage.NewLocalDiskBlobStorage(pool, spool), + postgres.NewInboundMessageRepository(pool), + ) + + mailboxRepo := postgres.NewMailboxRepository(pool) + recipientRecord, err := mailboxRepo.FindActiveByAddress(ctx, "postmaster@example.test") + if err != nil { + t.Fatalf("resolve recipient for seeding: %v", err) + } + if recipientRecord == nil { + t.Fatal("recipient not found - seeding order bug: mailbox must exist before this call") + } + + testMessage := "From: sender@remote.example\r\nTo: postmaster@example.test\r\nSubject: imap e2e\r\n\r\nHello via IMAP.\r\n" + if err := inboundUC.Handle(ctx, appusecase.ProcessInboundEmailInput{ + Sender: "sender@remote.example", + Recipients: []port.MailboxRecord{*recipientRecord}, + RecipientAddresses: []string{"postmaster@example.test"}, + Body: bytes.NewBufferString(testMessage), + }); err != nil { + t.Fatalf("seed message via SMTP use case: %v", err) + } + + // --- Real IMAP server, wired exactly like main.go ---------------------- + imapUC := appusecase.NewImapSessionUseCase( + postgres.NewAuthRepository(pool), + postgres.NewImapFolderRepository(pool), + postgres.NewMessageQueryRepository(pool), + postgres.NewFlagRepository(pool), + diskstorage.NewLocalDiskBlobReader(pool), + ) + server := imapserver.New(&imapserver.Options{ + NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return imappresentation.NewSession(c, imapUC) + }, + InsecureAuth: true, // no TLS in this test - InsecureAuth lets LOGIN proceed over plaintext + }) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go server.Serve(ln) + defer server.Close() + + // --- Real IMAP client drives the session -------------------------------- + client, err := imapclient.DialInsecure(ln.Addr().String(), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer client.Close() + + if err := client.Login("postmaster@example.test", password).Wait(); err != nil { + t.Fatalf("LOGIN: %v", err) + } + + selectData, err := client.Select("INBOX", nil).Wait() + if err != nil { + t.Fatalf("SELECT: %v", err) + } + if selectData.NumMessages != 1 { + t.Errorf("NumMessages = %d, want 1", selectData.NumMessages) + } + + // A real IMAP client's first action after LOGIN is typically + // LIST "" "*" - drive Session.List through the real imapserver stack + // (unlike the unit tests, which can't construct a *imapserver.ListWriter + // directly) and confirm INBOX comes back. + listEntries, err := client.List("", "*", nil).Collect() + if err != nil { + t.Fatalf("LIST: %v", err) + } + foundInbox := false + for _, entry := range listEntries { + if entry.Mailbox == "INBOX" { + foundInbox = true + } + } + if !foundInbox { + t.Errorf("LIST results = %+v, want an entry for INBOX", listEntries) + } + + fetchCmd := client.Fetch(imap.UIDSetNum(1), &imap.FetchOptions{ + UID: true, Flags: true, Envelope: true, + BodySection: []*imap.FetchItemBodySection{{}}, + }) + messages, err := fetchCmd.Collect() + if err != nil { + t.Fatalf("FETCH: %v", err) + } + if len(messages) != 1 { + t.Fatalf("fetched %d messages, want 1", len(messages)) + } + if messages[0].Envelope == nil || messages[0].Envelope.Subject != "imap e2e" { + t.Errorf("Envelope.Subject = %+v, want \"imap e2e\"", messages[0].Envelope) + } + + // Client.Store returns *FetchCommand (like Fetch does), not a plain + // Command - it has Collect(), not Wait(), since STORE (without SILENT) + // streams back the updated FETCH data per RFC 3501. + if _, err := client.Store(imap.UIDSetNum(1), &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen}, + }, nil).Collect(); err != nil { + t.Fatalf("STORE: %v", err) + } + + fetchAfterStore, err := client.Fetch(imap.UIDSetNum(1), &imap.FetchOptions{Flags: true}).Collect() + if err != nil { + t.Fatalf("FETCH after STORE: %v", err) + } + found := false + for _, f := range fetchAfterStore[0].Flags { + if f == imap.FlagSeen { + found = true + } + } + if !found { + t.Errorf("flags after STORE = %v, want to include \\Seen", fetchAfterStore[0].Flags) + } +} diff --git a/services/protocols/internal/infrastructure/diskstorage/local_disk_blob_reader.go b/services/protocols/internal/infrastructure/diskstorage/local_disk_blob_reader.go new file mode 100644 index 0000000..05836eb --- /dev/null +++ b/services/protocols/internal/infrastructure/diskstorage/local_disk_blob_reader.go @@ -0,0 +1,32 @@ +package diskstorage + +import ( + "context" + "fmt" + "os" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// LocalDiskBlobReader implements port.BlobReader by looking up the blob's +// storage_path in Postgres, then reading the full file from local disk. +type LocalDiskBlobReader struct { + pool *pgxpool.Pool +} + +func NewLocalDiskBlobReader(pool *pgxpool.Pool) *LocalDiskBlobReader { + return &LocalDiskBlobReader{pool: pool} +} + +func (r *LocalDiskBlobReader) ReadByID(ctx context.Context, blobID uuid.UUID) ([]byte, error) { + var path string + if err := r.pool.QueryRow(ctx, `SELECT storage_path FROM message_blobs WHERE id = $1`, blobID).Scan(&path); err != nil { + return nil, fmt.Errorf("find blob %s: %w", blobID, err) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read blob file %s: %w", path, err) + } + return data, nil +} diff --git a/services/protocols/internal/infrastructure/diskstorage/local_disk_blob_reader_test.go b/services/protocols/internal/infrastructure/diskstorage/local_disk_blob_reader_test.go new file mode 100644 index 0000000..8bd8bbf --- /dev/null +++ b/services/protocols/internal/infrastructure/diskstorage/local_disk_blob_reader_test.go @@ -0,0 +1,43 @@ +package diskstorage + +import ( + "bytes" + "context" + "testing" + + "github.com/google/uuid" +) + +func TestLocalDiskBlobReader_ReadByID_ReturnsStoredContent(t *testing.T) { + pool := testPoolForBlobStorage(t) + defer pool.Close() + dir := t.TempDir() + storage := NewLocalDiskBlobStorage(pool, dir) + reader := NewLocalDiskBlobReader(pool) + + content := []byte("Subject: read test\r\n\r\nhello reader") + ref, err := storage.Store(context.Background(), bytes.NewReader(content)) + if err != nil { + t.Fatalf("Store: %v", err) + } + defer pool.Exec(context.Background(), `DELETE FROM message_blobs WHERE id = $1`, ref.ID) + + got, err := reader.ReadByID(context.Background(), ref.ID) + if err != nil { + t.Fatalf("ReadByID: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("ReadByID content = %q, want %q", got, content) + } +} + +func TestLocalDiskBlobReader_ReadByID_ReturnsErrorForUnknownID(t *testing.T) { + pool := testPoolForBlobStorage(t) + defer pool.Close() + reader := NewLocalDiskBlobReader(pool) + + _, err := reader.ReadByID(context.Background(), uuid.New()) + if err == nil { + t.Fatal("expected an error for an unknown (random) blob ID, got nil") + } +} diff --git a/services/protocols/internal/infrastructure/postgres/auth_repository.go b/services/protocols/internal/infrastructure/postgres/auth_repository.go new file mode 100644 index 0000000..1aa7bec --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/auth_repository.go @@ -0,0 +1,42 @@ +package postgres + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// AuthRepository implements port.AuthRepository against Postgres. +type AuthRepository struct { + pool *pgxpool.Pool +} + +func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository { + return &AuthRepository{pool: pool} +} + +func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) { + row := r.pool.QueryRow(ctx, ` + SELECT m.id, m.password_hash + FROM mailboxes m + JOIN domains d ON d.id = m.domain_id + WHERE m.email_address = $1 + AND m.is_active = true + AND d.is_active = true + `, address) + + var rec port.MailboxAuth + var id uuid.UUID + if err := row.Scan(&id, &rec.PasswordHash); err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, err + } + rec.ID = id + return &rec, nil +} diff --git a/services/protocols/internal/infrastructure/postgres/auth_repository_test.go b/services/protocols/internal/infrastructure/postgres/auth_repository_test.go new file mode 100644 index 0000000..4c3034a --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/auth_repository_test.go @@ -0,0 +1,58 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + domainID := uuid.New() + domainName := "authrepo-test-" + domainID.String() + ".invalid" + _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, + domainID, domainName, domainName) + if err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + mailboxID := uuid.New() + address := "user@" + domainName + hash := "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG" + _, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, $4)`, + mailboxID, domainID, address, hash) + if err != nil { + t.Fatalf("seed mailbox: %v", err) + } + + repo := NewAuthRepository(pool) + rec, err := repo.FindByAddress(ctx, address) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec == nil { + t.Fatal("rec = nil, want a record") + } + if rec.ID != mailboxID || rec.PasswordHash != hash { + t.Errorf("rec = %+v, want ID=%v PasswordHash=%q", rec, mailboxID, hash) + } +} + +func TestAuthRepository_FindByAddress_ReturnsNilForUnknownAddress(t *testing.T) { + pool := testPool(t) + defer pool.Close() + + repo := NewAuthRepository(pool) + rec, err := repo.FindByAddress(context.Background(), "definitely-not-seeded@example.invalid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec != nil { + t.Errorf("rec = %+v, want nil", rec) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/flag_repository.go b/services/protocols/internal/infrastructure/postgres/flag_repository.go new file mode 100644 index 0000000..0f64161 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/flag_repository.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// FlagRepository implements port.FlagRepository against Postgres. +type FlagRepository struct { + pool *pgxpool.Pool +} + +func NewFlagRepository(pool *pgxpool.Pool) *FlagRepository { + return &FlagRepository{pool: pool} +} + +func (r *FlagRepository) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + var messageID string + var receivedAt any + if err := tx.QueryRow(ctx, ` + SELECT id, received_at FROM email_messages WHERE folder_id = $1 AND uid = $2 + `, folderID, uid).Scan(&messageID, &receivedAt); err != nil { + return fmt.Errorf("find message folder=%s uid=%d: %w", folderID, uid, err) + } + + switch op { + case port.FlagOpAdd: + for _, flag := range flags { + if _, err := tx.Exec(ctx, ` + INSERT INTO message_flags (message_id, received_at, flag) VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + `, messageID, receivedAt, flag); err != nil { + return fmt.Errorf("add flag %q: %w", flag, err) + } + } + case port.FlagOpDel: + for _, flag := range flags { + if _, err := tx.Exec(ctx, ` + DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = $3 + `, messageID, receivedAt, flag); err != nil { + return fmt.Errorf("delete flag %q: %w", flag, err) + } + } + case port.FlagOpSet: + if _, err := tx.Exec(ctx, `DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2`, messageID, receivedAt); err != nil { + return fmt.Errorf("clear flags before set: %w", err) + } + for _, flag := range flags { + if _, err := tx.Exec(ctx, ` + INSERT INTO message_flags (message_id, received_at, flag) VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + `, messageID, receivedAt, flag); err != nil { + return fmt.Errorf("set flag %q: %w", flag, err) + } + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} diff --git a/services/protocols/internal/infrastructure/postgres/flag_repository_test.go b/services/protocols/internal/infrastructure/postgres/flag_repository_test.go new file mode 100644 index 0000000..88be698 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/flag_repository_test.go @@ -0,0 +1,109 @@ +package postgres + +import ( + "context" + "sort" + "testing" + + "github.com/google/uuid" + "lambdamail/protocols/internal/application/port" +) + +func TestFlagRepository_SetFlags_AddThenDelThenSet(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + domainID := uuid.New() + domainName := "flagrepo-test-" + domainID.String() + ".invalid" + _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, + domainID, domainName, domainName) + if err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + mailboxID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`, + mailboxID, domainID, "user@"+domainName) + if err != nil { + t.Fatalf("seed mailbox: %v", err) + } + folderID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, + folderID, mailboxID) + if err != nil { + t.Fatalf("seed folder: %v", err) + } + blobID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, + blobID, "1111110000000000000000000000000000000000000000000000000000000000"[:64]) + if err != nil { + t.Fatalf("seed blob: %v", err) + } + messageID := uuid.New() + _, err = pool.Exec(ctx, ` + INSERT INTO email_messages (id, mailbox_id, folder_id, uid, blob_id, sender_address, recipient_addresses, size_bytes) + VALUES ($1, $2, $3, 7, $4, 'sender@example.test', $5, 10) + `, messageID, mailboxID, folderID, blobID, []string{"user@" + domainName}) + if err != nil { + t.Fatalf("seed message: %v", err) + } + + readFlags := func() []string { + t.Helper() + rows, err := pool.Query(ctx, `SELECT flag FROM message_flags WHERE message_id = $1 ORDER BY flag`, messageID) + if err != nil { + t.Fatalf("query flags: %v", err) + } + defer rows.Close() + var flags []string + for rows.Next() { + var f string + if err := rows.Scan(&f); err != nil { + t.Fatalf("scan flag: %v", err) + } + flags = append(flags, f) + } + return flags + } + + repo := NewFlagRepository(pool) + + if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpAdd, []string{"\\Seen", "\\Flagged"}); err != nil { + t.Fatalf("add: %v", err) + } + got := readFlags() + sort.Strings(got) + if len(got) != 2 || got[0] != "\\Flagged" || got[1] != "\\Seen" { + t.Fatalf("after add: flags = %v, want [\\Flagged \\Seen]", got) + } + + if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpDel, []string{"\\Flagged"}); err != nil { + t.Fatalf("del: %v", err) + } + got = readFlags() + if len(got) != 1 || got[0] != "\\Seen" { + t.Fatalf("after del: flags = %v, want [\\Seen]", got) + } + + if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpSet, []string{"\\Answered"}); err != nil { + t.Fatalf("set: %v", err) + } + got = readFlags() + if len(got) != 1 || got[0] != "\\Answered" { + t.Fatalf("after set: flags = %v, want [\\Answered] (Set replaces the full flag set)", got) + } + + // A client sending "STORE 1 FLAGS (\Seen \Seen)" - a duplicate flag in a + // single command - must not fail the whole STORE with a primary key + // violation on the second INSERT for the same (message_id, received_at, + // flag). + if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpSet, []string{"\\Seen", "\\Seen"}); err != nil { + t.Fatalf("set with duplicate flags in input: %v", err) + } + got = readFlags() + if len(got) != 1 || got[0] != "\\Seen" { + t.Fatalf("after set with duplicate input flags: flags = %v, want [\\Seen]", got) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/imap_folder_repository.go b/services/protocols/internal/infrastructure/postgres/imap_folder_repository.go new file mode 100644 index 0000000..034cb03 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/imap_folder_repository.go @@ -0,0 +1,59 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// ImapFolderRepository implements port.ImapFolderRepository against Postgres. +type ImapFolderRepository struct { + pool *pgxpool.Pool +} + +func NewImapFolderRepository(pool *pgxpool.Pool) *ImapFolderRepository { + return &ImapFolderRepository{pool: pool} +} + +func (r *ImapFolderRepository) FindByName(ctx context.Context, mailboxID, name string) (*port.ImapFolderRecord, error) { + row := r.pool.QueryRow(ctx, ` + SELECT f.id, f.name, f.uid_next, f.uid_validity, f.total_count + FROM folders f + WHERE f.mailbox_id = $1 AND f.name = $2 + `, mailboxID, name) + + var rec port.ImapFolderRecord + if err := row.Scan(&rec.ID, &rec.Name, &rec.UIDNext, &rec.UIDValidity, &rec.NumMessages); err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, err + } + return &rec, nil +} + +func (r *ImapFolderRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.ImapFolderRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT f.id, f.name, f.uid_next, f.uid_validity, f.total_count + FROM folders f + WHERE f.mailbox_id = $1 + ORDER BY f.name + `, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []port.ImapFolderRecord + for rows.Next() { + var rec port.ImapFolderRecord + if err := rows.Scan(&rec.ID, &rec.Name, &rec.UIDNext, &rec.UIDValidity, &rec.NumMessages); err != nil { + return nil, err + } + out = append(out, rec) + } + return out, rows.Err() +} diff --git a/services/protocols/internal/infrastructure/postgres/imap_folder_repository_test.go b/services/protocols/internal/infrastructure/postgres/imap_folder_repository_test.go new file mode 100644 index 0000000..dc7d701 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/imap_folder_repository_test.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +func TestImapFolderRepository_FindByName_ReturnsFolderWithUIDData(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + domainID := uuid.New() + domainName := "imapfolder-test-" + domainID.String() + ".invalid" + _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, + domainID, domainName, domainName) + if err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + mailboxID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`, + mailboxID, domainID, "user@"+domainName) + if err != nil { + t.Fatalf("seed mailbox: %v", err) + } + + folderID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use, uid_next, uid_validity) VALUES ($1, $2, 'INBOX', 'inbox', 5, 12345)`, + folderID, mailboxID) + if err != nil { + t.Fatalf("seed folder: %v", err) + } + + repo := NewImapFolderRepository(pool) + rec, err := repo.FindByName(ctx, mailboxID.String(), "INBOX") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec == nil { + t.Fatal("rec = nil, want a folder record") + } + if rec.UIDNext != 5 || rec.UIDValidity != 12345 { + t.Errorf("rec = %+v, want UIDNext=5 UIDValidity=12345", rec) + } +} + +func TestImapFolderRepository_FindByName_ReturnsNilForUnknownFolder(t *testing.T) { + pool := testPool(t) + defer pool.Close() + + repo := NewImapFolderRepository(pool) + rec, err := repo.FindByName(context.Background(), uuid.New().String(), "Nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec != nil { + t.Errorf("rec = %+v, want nil", rec) + } +} + +func TestImapFolderRepository_ListFolders_ReturnsAllFoldersForMailbox(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + domainID := uuid.New() + domainName := "imapfolder-list-" + domainID.String() + ".invalid" + _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, + domainID, domainName, domainName) + if err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + mailboxID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`, + mailboxID, domainID, "user@"+domainName) + if err != nil { + t.Fatalf("seed mailbox: %v", err) + } + _, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, + uuid.New(), mailboxID) + if err != nil { + t.Fatalf("seed INBOX: %v", err) + } + _, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name) VALUES ($1, $2, 'Archive')`, + uuid.New(), mailboxID) + if err != nil { + t.Fatalf("seed Archive: %v", err) + } + + repo := NewImapFolderRepository(pool) + recs, err := repo.ListFolders(ctx, mailboxID.String()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(recs) != 2 { + t.Errorf("len(recs) = %d, want 2", len(recs)) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/message_query_repository.go b/services/protocols/internal/infrastructure/postgres/message_query_repository.go new file mode 100644 index 0000000..3ac2479 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/message_query_repository.go @@ -0,0 +1,44 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// MessageQueryRepository implements port.MessageQueryRepository against Postgres. +type MessageQueryRepository struct { + pool *pgxpool.Pool +} + +func NewMessageQueryRepository(pool *pgxpool.Pool) *MessageQueryRepository { + return &MessageQueryRepository{pool: pool} +} + +func (r *MessageQueryRepository) ListMessages(ctx context.Context, folderID string) ([]port.MessageRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT m.uid, m.blob_id, m.size_bytes, m.received_at, + COALESCE(array_agg(f.flag) FILTER (WHERE f.flag IS NOT NULL), '{}') + FROM email_messages m + LEFT JOIN message_flags f ON f.message_id = m.id AND f.received_at = m.received_at + WHERE m.folder_id = $1 AND m.expunged_at IS NULL + GROUP BY m.uid, m.blob_id, m.size_bytes, m.received_at + ORDER BY m.uid ASC + `, folderID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []port.MessageRecord + for rows.Next() { + var rec port.MessageRecord + if err := rows.Scan(&rec.UID, &rec.BlobID, &rec.SizeBytes, &rec.ReceivedAt, &rec.Flags); err != nil { + return nil, err + } + out = append(out, rec) + } + return out, rows.Err() +} diff --git a/services/protocols/internal/infrastructure/postgres/message_query_repository_test.go b/services/protocols/internal/infrastructure/postgres/message_query_repository_test.go new file mode 100644 index 0000000..3eff5ce --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/message_query_repository_test.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +func TestMessageQueryRepository_ListMessages_ReturnsRowsOrderedByUID(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + domainID := uuid.New() + domainName := "msgquery-test-" + domainID.String() + ".invalid" + _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, + domainID, domainName, domainName) + if err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + mailboxID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`, + mailboxID, domainID, "user@"+domainName) + if err != nil { + t.Fatalf("seed mailbox: %v", err) + } + folderID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, + folderID, mailboxID) + if err != nil { + t.Fatalf("seed folder: %v", err) + } + blobID := uuid.New() + _, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 42)`, + blobID, "abcdef0000000000000000000000000000000000000000000000000000000000"[:64]) + if err != nil { + t.Fatalf("seed blob: %v", err) + } + + for _, uidVal := range []int64{2, 1} { // insert out of order to prove the query sorts + _, err = pool.Exec(ctx, ` + INSERT INTO email_messages (mailbox_id, folder_id, uid, blob_id, sender_address, recipient_addresses, size_bytes) + VALUES ($1, $2, $3, $4, 'sender@example.test', $5, 42) + `, mailboxID, folderID, uidVal, blobID, []string{"user@" + domainName}) + if err != nil { + t.Fatalf("seed message uid=%d: %v", uidVal, err) + } + } + + repo := NewMessageQueryRepository(pool) + recs, err := repo.ListMessages(ctx, folderID.String()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(recs) != 2 { + t.Fatalf("len(recs) = %d, want 2", len(recs)) + } + if recs[0].UID != 1 || recs[1].UID != 2 { + t.Errorf("UIDs = [%d, %d], want [1, 2] (ascending order)", recs[0].UID, recs[1].UID) + } + if recs[0].BlobID != blobID { + t.Errorf("BlobID = %v, want %v", recs[0].BlobID, blobID) + } +} diff --git a/services/protocols/internal/presentation/imap/backend.go b/services/protocols/internal/presentation/imap/backend.go new file mode 100644 index 0000000..f4c3e8c --- /dev/null +++ b/services/protocols/internal/presentation/imap/backend.go @@ -0,0 +1,254 @@ +package imappresentation + +import ( + "bufio" + "bytes" + "context" + "errors" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/emersion/go-message/textproto" + + "lambdamail/protocols/internal/application/port" + "lambdamail/protocols/internal/application/usecase" +) + +// session adapts imapserver.Session to ImapSessionUseCase. +type session struct { + useCase *usecase.ImapSessionUseCase + mailboxID string + selectedFolder *port.ImapFolderRecord +} + +// NewSession is the imapserver.Options.NewSession callback. +func NewSession(_ *imapserver.Conn, uc *usecase.ImapSessionUseCase) (imapserver.Session, *imapserver.GreetingData, error) { + return &session{useCase: uc}, &imapserver.GreetingData{}, nil +} + +func toIMAPError(err error) error { + switch { + case errors.Is(err, usecase.ErrAuthFailed): + return imapserver.ErrAuthFailed + case errors.Is(err, usecase.ErrNoSuchFolder): + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No such mailbox"} + default: + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Internal error"} + } +} + +func (s *session) Close() error { return nil } + +func (s *session) Login(username, password string) error { + mailboxID, err := s.useCase.Login(context.Background(), username, password) + if err != nil { + return toIMAPError(err) + } + s.mailboxID = mailboxID + return nil +} + +func (s *session) Select(mailbox string, _ *imap.SelectOptions) (*imap.SelectData, error) { + rec, err := s.useCase.SelectFolder(context.Background(), s.mailboxID, mailbox) + if err != nil { + return nil, toIMAPError(err) + } + s.selectedFolder = rec + return &imap.SelectData{ + NumMessages: rec.NumMessages, + UIDNext: imap.UID(rec.UIDNext), + UIDValidity: rec.UIDValidity, + }, nil +} + +func (s *session) Unselect() error { + s.selectedFolder = nil + return nil +} + +func (s *session) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) { + rec, err := s.useCase.SelectFolder(context.Background(), s.mailboxID, mailbox) + if err != nil { + return nil, toIMAPError(err) + } + data := &imap.StatusData{Mailbox: mailbox} + if options.NumMessages { + n := rec.NumMessages + data.NumMessages = &n + } + if options.UIDNext { + data.UIDNext = imap.UID(rec.UIDNext) + } + if options.UIDValidity { + data.UIDValidity = rec.UIDValidity + } + return data, nil +} + +func (s *session) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { + recs, err := s.useCase.ListFolders(context.Background(), s.mailboxID) + if err != nil { + return toIMAPError(err) + } + for _, rec := range matchFolders(recs, ref, patterns) { + if err := w.WriteList(&imap.ListData{Mailbox: rec.Name, Delim: '/'}); err != nil { + return err + } + } + return nil +} + +// matchFolders filters recs down to those whose name matches at least one of +// patterns (relative to ref), using the same wildcard semantics as the LIST +// command (imapserver.MatchList: '*' matches any characters including the +// hierarchy delimiter, '%' matches any characters except the delimiter). +// Extracted from List so the matching/filtering logic can be unit-tested +// directly, since *imapserver.ListWriter cannot be constructed outside a +// live imapserver.Conn. +func matchFolders(recs []port.ImapFolderRecord, ref string, patterns []string) []port.ImapFolderRecord { + var out []port.ImapFolderRecord + for _, rec := range recs { + for _, pattern := range patterns { + if imapserver.MatchList(rec.Name, '/', ref, pattern) { + out = append(out, rec) + break + } + } + } + return out +} + +// numSetIter walks every message in the selected folder in UID order, +// yielding its 1-based sequence number alongside it, and reports whether +// numSet contains that message (matching imapmemserver's own reference +// pattern of iterating known messages and calling NumSet.Contains, rather +// than trying to resolve "*" ranges ourselves). +func (s *session) numSetIter(numSet imap.NumSet, isUID bool, messages []port.MessageRecord, f func(seqNum uint32, msg port.MessageRecord)) { + for i, msg := range messages { + seqNum := uint32(i) + 1 + var contains bool + switch set := numSet.(type) { + case imap.SeqSet: + contains = set.Contains(seqNum) + case imap.UIDSet: + contains = set.Contains(imap.UID(msg.UID)) + } + if contains { + f(seqNum, msg) + } + } +} + +func (s *session) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { + if s.selectedFolder == nil { + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No mailbox selected"} + } + messages, err := s.useCase.FetchMessages(context.Background(), s.selectedFolder.ID) + if err != nil { + return toIMAPError(err) + } + + var fetchErr error + s.numSetIter(numSet, options.UID, messages, func(seqNum uint32, msg port.MessageRecord) { + if fetchErr != nil { + return + } + respWriter := w.CreateMessage(seqNum) + fetchErr = s.writeFetchResponse(respWriter, msg, options) + }) + return fetchErr +} + +func (s *session) writeFetchResponse(w *imapserver.FetchResponseWriter, msg port.MessageRecord, options *imap.FetchOptions) error { + w.WriteUID(imap.UID(msg.UID)) + if options.Flags { + flags := make([]imap.Flag, len(msg.Flags)) + for i, f := range msg.Flags { + flags[i] = imap.Flag(f) + } + w.WriteFlags(flags) + } + if options.InternalDate { + w.WriteInternalDate(msg.ReceivedAt) + } + if options.RFC822Size { + w.WriteRFC822Size(msg.SizeBytes) + } + + needsRaw := options.Envelope || options.BodyStructure != nil || len(options.BodySection) > 0 || len(options.BinarySection) > 0 + if needsRaw { + raw, err := s.useCase.ReadBlob(context.Background(), msg.BlobID) + if err != nil { + return err + } + if options.Envelope { + header, err := textproto.ReadHeader(bufio.NewReader(bytes.NewReader(raw))) + if err != nil { + return err + } + w.WriteEnvelope(imapserver.ExtractEnvelope(header)) + } + if options.BodyStructure != nil { + w.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(raw))) + } + for _, bs := range options.BodySection { + buf := imapserver.ExtractBodySection(bytes.NewReader(raw), bs) + wc := w.WriteBodySection(bs, int64(len(buf))) + _, writeErr := wc.Write(buf) + closeErr := wc.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + } + } + return w.Close() +} + +func (s *session) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { + if s.selectedFolder == nil { + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No mailbox selected"} + } + var op port.FlagOp + switch flags.Op { + case imap.StoreFlagsAdd: + op = port.FlagOpAdd + case imap.StoreFlagsDel: + op = port.FlagOpDel + case imap.StoreFlagsSet: + op = port.FlagOpSet + } + flagNames := make([]string, len(flags.Flags)) + for i, f := range flags.Flags { + flagNames[i] = string(f) + } + + messages, err := s.useCase.FetchMessages(context.Background(), s.selectedFolder.ID) + if err != nil { + return toIMAPError(err) + } + + // Resolve numSet against the folder's real message list (same pattern as + // Fetch), rather than pulling raw values via NumSet.Nums(): dynamic + // ranges like "1:*" cannot be resolved without a concrete candidate to + // test against, and Nums() returns (nil, false) for them. This also + // naturally supports plain (non-UID) STORE via the SeqSet case. + var storeErr error + s.numSetIter(numSet, true, messages, func(_ uint32, msg port.MessageRecord) { + if storeErr != nil { + return + } + if err := s.useCase.SetFlags(context.Background(), s.selectedFolder.ID, msg.UID, op, flagNames); err != nil { + storeErr = toIMAPError(err) + } + }) + if storeErr != nil { + return storeErr + } + if !flags.Silent { + return s.Fetch(w, numSet, &imap.FetchOptions{Flags: true, UID: true}) + } + return nil +} diff --git a/services/protocols/internal/presentation/imap/backend_test.go b/services/protocols/internal/presentation/imap/backend_test.go new file mode 100644 index 0000000..b489429 --- /dev/null +++ b/services/protocols/internal/presentation/imap/backend_test.go @@ -0,0 +1,423 @@ +package imappresentation + +import ( + "context" + "errors" + "testing" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/google/uuid" + + "lambdamail/protocols/internal/application/port" + "lambdamail/protocols/internal/application/usecase" +) + +type fakeAuthRepository struct { + byAddress map[string]port.MailboxAuth +} + +func (f *fakeAuthRepository) FindByAddress(_ context.Context, address string) (*port.MailboxAuth, error) { + rec, ok := f.byAddress[address] + if !ok { + return nil, nil + } + return &rec, nil +} + +type fakeImapFolderRepository struct { + byName map[string]port.ImapFolderRecord +} + +func (f *fakeImapFolderRepository) ListFolders(_ context.Context, _ string) ([]port.ImapFolderRecord, error) { + var out []port.ImapFolderRecord + for _, rec := range f.byName { + out = append(out, rec) + } + return out, nil +} + +func (f *fakeImapFolderRepository) FindByName(_ context.Context, _ string, name string) (*port.ImapFolderRecord, error) { + rec, ok := f.byName[name] + if !ok { + return nil, nil + } + return &rec, nil +} + +type fakeMessageQueryRepository struct { + messages []port.MessageRecord +} + +func (f *fakeMessageQueryRepository) ListMessages(_ context.Context, _ string) ([]port.MessageRecord, error) { + return f.messages, nil +} + +type fakeFlagRepository struct { + lastFolderID string + lastUID uint32 + lastOp port.FlagOp + lastFlags []string + + calls []fakeFlagCall +} + +type fakeFlagCall struct { + folderID string + uid uint32 + op port.FlagOp + flags []string +} + +func (f *fakeFlagRepository) SetFlags(_ context.Context, folderID string, uid uint32, op port.FlagOp, flags []string) error { + f.lastFolderID, f.lastUID, f.lastOp, f.lastFlags = folderID, uid, op, flags + f.calls = append(f.calls, fakeFlagCall{folderID: folderID, uid: uid, op: op, flags: flags}) + return nil +} + +type fakeBlobReader struct { + content map[uuid.UUID][]byte +} + +func (f *fakeBlobReader) ReadByID(_ context.Context, blobID uuid.UUID) ([]byte, error) { + return f.content[blobID], nil +} + +// testPasswordHash/testPassword duplicate the constants from +// internal/application/usecase/imap_session_test.go - a real Argon2id PHC +// hash for "correct horse battery staple", generated once with +// argon2id.CreateHash and hardcoded (see that file's Task 2 note for how +// to generate it). +const ( + testPassword = "correct horse battery staple" + testPasswordHash = "$argon2id$v=19$m=65536,t=1,p=12$I88aUc0rZHHX+Bt+jX+4Ag$3jKTtg1khJjFo9az4iPvFLc3eKY8WKSFy/1YMXlKvSU" +) + +func newTestSession(auth *fakeAuthRepository, folders *fakeImapFolderRepository, messages *fakeMessageQueryRepository, flags *fakeFlagRepository, blobs *fakeBlobReader) imapserver.Session { + uc := usecase.NewImapSessionUseCase(auth, folders, messages, flags, blobs) + sess, _, err := NewSession(nil, uc) + if err != nil { + panic(err) + } + return sess +} + +func TestSession_Login_SucceedsForCorrectCredentials(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + sess := newTestSession(auth, nil, nil, nil, nil) + + if err := sess.Login("user@example.test", testPassword); err != nil { + t.Fatalf("Login: %v", err) + } +} + +func TestSession_Login_FailsForWrongPassword(t *testing.T) { + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: uuid.New(), PasswordHash: testPasswordHash}, + }} + sess := newTestSession(auth, nil, nil, nil, nil) + + err := sess.Login("user@example.test", "wrong") + if err == nil { + t.Fatal("expected an error for wrong password, got nil") + } + var imapErr *imap.Error + if !errors.As(err, &imapErr) { + t.Fatalf("error is not an *imap.Error: %v", err) + } + if imapErr.Type != imap.StatusResponseTypeNo { + t.Errorf("Type = %v, want StatusResponseTypeNo", imapErr.Type) + } +} + +func TestSession_Select_ReturnsDataForKnownFolder(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{ + "INBOX": {ID: "folder-1", Name: "INBOX", UIDNext: 5, UIDValidity: 999, NumMessages: 3}, + }} + sess := newTestSession(auth, folders, &fakeMessageQueryRepository{}, &fakeFlagRepository{}, &fakeBlobReader{}) + if err := sess.Login("user@example.test", testPassword); err != nil { + t.Fatalf("Login: %v", err) + } + + data, err := sess.Select("INBOX", &imap.SelectOptions{}) + if err != nil { + t.Fatalf("Select: %v", err) + } + if data.UIDNext != 5 || data.UIDValidity != 999 || data.NumMessages != 3 { + t.Errorf("data = %+v, want UIDNext=5 UIDValidity=999 NumMessages=3", data) + } +} + +func TestSession_Select_FailsForUnknownFolder(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{}} + sess := newTestSession(auth, folders, &fakeMessageQueryRepository{}, &fakeFlagRepository{}, &fakeBlobReader{}) + _ = sess.Login("user@example.test", testPassword) + + _, err := sess.Select("Nonexistent", &imap.SelectOptions{}) + if err == nil { + t.Fatal("expected an error for an unknown folder, got nil") + } +} + +func TestSession_Store_CallsFlagRepositoryWithCorrectArgs(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{ + "INBOX": {ID: "folder-1", Name: "INBOX"}, + }} + flags := &fakeFlagRepository{} + messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{{UID: 7}}} + sess := newTestSession(auth, folders, messages, flags, &fakeBlobReader{}) + _ = sess.Login("user@example.test", testPassword) + if _, err := sess.Select("INBOX", &imap.SelectOptions{}); err != nil { + t.Fatalf("Select: %v", err) + } + + numSet := imap.UIDSetNum(7) + err := sess.Store(nil, numSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, &imap.StoreOptions{}) + if err != nil { + t.Fatalf("Store: %v", err) + } + if flags.lastFolderID != "folder-1" || flags.lastUID != 7 || flags.lastOp != port.FlagOpAdd { + t.Errorf("flags recorded = folderID=%q uid=%d op=%v, want folder-1/7/FlagOpAdd", flags.lastFolderID, flags.lastUID, flags.lastOp) + } +} + +// TestSession_Store_HandlesUIDStarRange proves that UID STORE with a dynamic +// open-ended range (e.g. "UID STORE 5:* +FLAGS (\Seen)", the "mark all as +// read" operation) resolves against the folder's real message list instead +// of relying on NumSet.Nums(), which returns (nil, false) for such ranges. +func TestSession_Store_HandlesUIDStarRange(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{ + "INBOX": {ID: "folder-1", Name: "INBOX"}, + }} + flags := &fakeFlagRepository{} + messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{ + {UID: 5}, {UID: 7}, {UID: 9}, + }} + sess := newTestSession(auth, folders, messages, flags, &fakeBlobReader{}) + _ = sess.Login("user@example.test", testPassword) + if _, err := sess.Select("INBOX", &imap.SelectOptions{}); err != nil { + t.Fatalf("Select: %v", err) + } + + // "5:*" - an open-ended dynamic range, the single most common real-world + // STORE usage ("mark all as read"). + var numSet imap.UIDSet + numSet.AddRange(5, 0) + + err := sess.Store(nil, numSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, &imap.StoreOptions{}) + if err != nil { + t.Fatalf("Store: %v", err) + } + + if len(flags.calls) != 3 { + t.Fatalf("SetFlags called %d times, want 3 (all messages with UID >= 5)", len(flags.calls)) + } + gotUIDs := map[uint32]bool{} + for _, c := range flags.calls { + gotUIDs[c.uid] = true + if c.op != port.FlagOpAdd || c.folderID != "folder-1" { + t.Errorf("call = %+v, want op=FlagOpAdd folderID=folder-1", c) + } + } + for _, want := range []uint32{5, 7, 9} { + if !gotUIDs[want] { + t.Errorf("SetFlags was not called for UID %d", want) + } + } +} + +// TestSession_Store_AllowsSequenceNumberSet proves that plain (non-UID) +// STORE, identified by a SeqSet rather than a UIDSet, is accepted and +// resolved against the message at that sequence position - the +// numSetIter-based design supports this the same way Fetch does, unlike the +// old implementation, which rejected any non-UIDSet outright. +func TestSession_Store_AllowsSequenceNumberSet(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{ + "INBOX": {ID: "folder-1", Name: "INBOX"}, + }} + flags := &fakeFlagRepository{} + messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{ + {UID: 5}, {UID: 7}, {UID: 9}, + }} + sess := newTestSession(auth, folders, messages, flags, &fakeBlobReader{}) + _ = sess.Login("user@example.test", testPassword) + if _, err := sess.Select("INBOX", &imap.SelectOptions{}); err != nil { + t.Fatalf("Select: %v", err) + } + + // Sequence number 1 refers to the first message in the folder's list + // (UID 5), not to "UID 1". + numSet := imap.SeqSetNum(1) + + err := sess.Store(nil, numSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, &imap.StoreOptions{}) + if err != nil { + t.Fatalf("Store: %v", err) + } + + if len(flags.calls) != 1 { + t.Fatalf("SetFlags called %d times, want 1", len(flags.calls)) + } + if flags.calls[0].uid != 5 || flags.calls[0].folderID != "folder-1" || flags.calls[0].op != port.FlagOpAdd { + t.Errorf("call = %+v, want uid=5 folderID=folder-1 op=FlagOpAdd", flags.calls[0]) + } +} + +// TestMatchFolders_FiltersByWildcardPattern exercises the folder-matching +// logic extracted from Session.List (matchFolders). Session.List itself +// cannot be driven directly in a unit test because *imapserver.ListWriter +// has no exported constructor and can only be built by a live +// *imapserver.Conn - so this test proves the matching/filtering behavior +// that List delegates to, and fails if that logic regresses (e.g. matching +// nothing, matching everything regardless of pattern, or mishandling the +// hierarchy delimiter). +func TestMatchFolders_FiltersByWildcardPattern(t *testing.T) { + recs := []port.ImapFolderRecord{ + {ID: "1", Name: "INBOX"}, + {ID: "2", Name: "Archive"}, + {ID: "3", Name: "Archive/2024"}, + {ID: "4", Name: "Sent"}, + } + + tests := []struct { + name string + ref string + patterns []string + want []string + }{ + { + name: "star matches everything including nested", + ref: "", + patterns: []string{"*"}, + want: []string{"INBOX", "Archive", "Archive/2024", "Sent"}, + }, + { + name: "percent does not cross hierarchy delimiter", + ref: "", + patterns: []string{"%"}, + want: []string{"INBOX", "Archive", "Sent"}, + }, + { + name: "exact literal match", + ref: "", + patterns: []string{"INBOX"}, + want: []string{"INBOX"}, + }, + { + name: "multiple patterns are unioned", + ref: "", + patterns: []string{"INBOX", "Sent"}, + want: []string{"INBOX", "Sent"}, + }, + { + name: "no pattern matches anything", + ref: "", + patterns: []string{"Nonexistent"}, + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := matchFolders(recs, tc.ref, tc.patterns) + var gotNames []string + for _, rec := range got { + gotNames = append(gotNames, rec.Name) + } + if len(gotNames) != len(tc.want) { + t.Fatalf("matchFolders() = %v, want %v", gotNames, tc.want) + } + wantSet := map[string]bool{} + for _, w := range tc.want { + wantSet[w] = true + } + for _, n := range gotNames { + if !wantSet[n] { + t.Errorf("matchFolders() included unexpected folder %q; got %v, want %v", n, gotNames, tc.want) + } + } + }) + } +} + +// TestSession_List_UsesFolderRepository proves Session.List consults +// useCase.ListFolders (via the fake ImapFolderRepository) rather than some +// hardcoded set - a sanity check that List is actually wired to real data, +// complementing the matchFolders unit test above and the full E2E LIST +// assertion in imap_core_e2e_test.go which drives List through a real +// *imapserver.ListWriter. +func TestSession_List_UsesFolderRepository(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{ + "INBOX": {ID: "folder-1", Name: "INBOX"}, + }} + sess := newTestSession(auth, folders, &fakeMessageQueryRepository{}, &fakeFlagRepository{}, &fakeBlobReader{}).(*session) + if err := sess.Login("user@example.test", testPassword); err != nil { + t.Fatalf("Login: %v", err) + } + + recs, err := sess.useCase.ListFolders(context.Background(), sess.mailboxID) + if err != nil { + t.Fatalf("ListFolders: %v", err) + } + matched := matchFolders(recs, "", []string{"*"}) + if len(matched) != 1 || matched[0].Name != "INBOX" { + t.Errorf("matched = %+v, want a single INBOX record", matched) + } +} + +func TestStubMethods_ReturnNotYetImplementedError(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + sess := newTestSession(auth, &fakeImapFolderRepository{}, &fakeMessageQueryRepository{}, &fakeFlagRepository{}, &fakeBlobReader{}) + _ = sess.Login("user@example.test", testPassword) + + if err := sess.Create("Nonexistent", nil); err == nil { + t.Error("Create: expected an error, got nil") + } + if _, err := sess.Search(imapserver.NumKindUID, &imap.SearchCriteria{}, &imap.SearchOptions{}); err == nil { + t.Error("Search: expected an error, got nil") + } + if _, err := sess.Copy(imap.UIDSetNum(1), "Other"); err == nil { + t.Error("Copy: expected an error, got nil") + } +} diff --git a/services/protocols/internal/presentation/imap/stubs.go b/services/protocols/internal/presentation/imap/stubs.go new file mode 100644 index 0000000..9db04e1 --- /dev/null +++ b/services/protocols/internal/presentation/imap/stubs.go @@ -0,0 +1,47 @@ +// Package imappresentation adapts github.com/emersion/go-imap/v2's +// imapserver.Session interface to ImapSessionUseCase (Clean Architecture +// layer 4 - PLAN.md section 3). This file holds every method NOT +// implemented for real in this sub-project: each returns a clear IMAP NO +// error, never a panic, never a silent no-op (see the design doc's +// "all-or-nothing interface problem" section for why every one of these +// 18 methods must exist even though only 7 do real work here). +package imappresentation + +import ( + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" +) + +var errNotYetImplemented = &imap.Error{ + Type: imap.StatusResponseTypeNo, + Text: "not yet implemented", +} + +func (s *session) Create(_ string, _ *imap.CreateOptions) error { return errNotYetImplemented } +func (s *session) Delete(_ string) error { return errNotYetImplemented } +func (s *session) Rename(_, _ string, _ *imap.RenameOptions) error { return errNotYetImplemented } +func (s *session) Subscribe(_ string) error { return errNotYetImplemented } +func (s *session) Unsubscribe(_ string) error { return errNotYetImplemented } +func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) { + return nil, errNotYetImplemented +} +// Poll is called by imapserver before the status response of nearly every +// command in the Authenticated/Selected state (see conn.go's c.poll), not +// just in response to an explicit client IDLE/NOOP - returning an error here +// would fail every real command (FETCH, STORE, ...), not just polling. This +// sub-project doesn't push unsolicited EXISTS/EXPUNGE/FETCH updates (no +// IDLE support - see Idle below), so reporting "no updates" via a nil +// return is the correct, spec-compliant no-op, not a stub. +func (s *session) Poll(_ *imapserver.UpdateWriter, _ bool) error { return nil } +func (s *session) Idle(_ *imapserver.UpdateWriter, _ <-chan struct{}) error { + return errNotYetImplemented +} +func (s *session) Expunge(_ *imapserver.ExpungeWriter, _ *imap.UIDSet) error { + return errNotYetImplemented +} +func (s *session) Search(_ imapserver.NumKind, _ *imap.SearchCriteria, _ *imap.SearchOptions) (*imap.SearchData, error) { + return nil, errNotYetImplemented +} +func (s *session) Copy(_ imap.NumSet, _ string) (*imap.CopyData, error) { + return nil, errNotYetImplemented +}