#4: F2 sub-project 1 - IMAP core (auth, LIST, SELECT, FETCH, STORE)

Merges the first F2 sub-project: a real :143 IMAP listener with Argon2id
auth, folder listing/selection, message FETCH (real ENVELOPE/BODY[] from
stored MIME), and flag STORE. Verified end-to-end with a real go-imap/v2
client against a message seeded through F1's SMTP path. See commit
9fa1929 for full details.
This commit is contained in:
Lucas Rayan committed 2026-07-23 20:34:58 -03:00
commit de082a190c
25 files changed
+1920 -1

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ services:
build: { context: ./services/protocols } build: { context: ./services/protocols }
restart: unless-stopped restart: unless-stopped
logging: *default-logging logging: *default-logging
ports: ["8080:8080", "25:25"] ports: ["8080:8080", "25:25", "143:143"]
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost} PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost}
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/emersion/go-imap/v2/imapserver"
gosmtp "github.com/emersion/go-smtp" gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/application/usecase"
@@ -15,6 +16,7 @@ import (
"lambdamail/protocols/internal/infrastructure/diskstorage" "lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres" "lambdamail/protocols/internal/infrastructure/postgres"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls" tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
imappresentation "lambdamail/protocols/internal/presentation/imap"
smtppresentation "lambdamail/protocols/internal/presentation/smtp" 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) }) handler := health.Handler(func() error { return pool.Ping(ctx) })
addr := ":8080" addr := ":8080"
log.Printf("lambdamail-protocols health listening on %s", addr) log.Printf("lambdamail-protocols health listening on %s", addr)
+5
View File
@@ -9,6 +9,9 @@ require (
) )
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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fergusstrange/embedded-postgres v1.30.0 // indirect github.com/fergusstrange/embedded-postgres v1.30.0 // indirect
github.com/jackc/pgpassfile v1.0.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/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // 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/sync v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.29.0 // indirect
) )
+47
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 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= 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/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 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= 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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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()
}
@@ -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))
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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
}