#4: implement F2 sub-project 1 - IMAP core (auth, LIST, SELECT, FETCH, STORE)
Adds a real :143 IMAP listener to the Go protocols service: a mailbox owner authenticates over IMAP (Argon2id against mailboxes.password_hash, enumeration-resistant per PLAN.md section 14.6), lists folders, selects INBOX, fetches messages F1's SMTP path already persisted - including real ENVELOPE/BODY[] built from the actual stored MIME bytes via go-imap/v2's own ExtractEnvelope/ExtractBodySection helpers, not reconstructed from denormalized columns - and sets/reads flags via STORE. - ImapSessionUseCase orchestrates auth, folder listing/selection, message querying, and flag mutation against new Postgres repositories (auth, folders, message query, flags) and a local-disk blob reader. - A github.com/emersion/go-imap/v2 (pinned v2.0.0-beta.8) Backend/Session implements 7 real methods (Login, List, Select, Unselect, Status, Fetch, Store) plus a correct no-op Poll; the other 10 of the interface's 18 methods (Create/Delete/Rename/Subscribe/Unsubscribe/Append/Idle/Expunge/ Search/Copy) are explicit stubs returning a clear "not yet implemented" IMAP error - SEARCH/EXPUNGE/COPY/MOVE, IDLE/CONDSTORE, mailbox management, and APPEND are separate follow-up sub-projects per the design doc's agreed ordering with the user. Verified end-to-end with a real go-imap/v2 imapclient (embedded Postgres, no Docker needed) driving Login -> Select -> Fetch -> Store -> List against a message seeded through F1's real SMTP use case, proving true SMTP-write/IMAP-read interop, not just isolated unit tests. Built via 8 sequential tasks under subagent-driven development, each independently reviewed, plus a final whole-branch review. Two real bugs were found and fixed before merge, both exactly the kind unit tests with fakes cannot catch: - Store's UID-set resolution silently no-op'd on dynamic ranges like "UID STORE 1:*" (the common "mark all as read" operation) after an earlier draft bypassed the folder's real message list; restored to resolve against real messages via the same pattern Fetch already used correctly, with tests proving both dynamic ranges and non-UID STORE. - go-imap/v2's server calls Session.Poll() implicitly before the status response of nearly every authenticated command (FETCH/STORE/SEARCH/ APPEND/COPY), not just IDLE as assumed - a hard-stubbed Poll broke every real command through this path; fixed to a correct no-op. - List had zero test coverage despite being committed scope; added unit tests for wildcard/delimiter matching plus a real E2E LIST assertion. Known follow-up, not blocking: a pre-existing test-fixture hygiene gap (hardcoded content_sha256 blob hashes with no cleanup, shared across several Postgres integration test files including one new to this branch) causes local-dev test collisions on repeated runs against a persistent database - invisible to CI (fresh Postgres per run) but worth a cleanup pass.
This commit is contained in:
1 parent
fc8bbaa3ce
commit
9fa19291d4
25 files changed
+1920
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user