A message sent over SMTP submission was delivered but left nothing behind: the account's Sent folder stayed empty. Webmail files its own copy after sending, and a mail client cannot do it either, since IMAP APPEND is not implemented, so nothing ever filled Sent for SMTP clients. - ProcessOutboundEmailInput.FileSentCopy asks Submit to file the signed message in the sender's Sent folder, as read (not unread), sharing the blob the delivery jobs already reference; one copy per submission, not per recipient - the SMTP submission session sets it; the webmail leaves it off because it files its own copy without the Bcc line, and asking twice would duplicate it - filed only after the message is durably queued, so a submission refused by the send limit leaves nothing behind; a failure to file is logged, never returned, because the mail is already on its way and the client would send it again Tests: unit cases (asked/not asked, shared blob, failure swallowed, limit refusal, no repository) and an end-to-end test on the real submission server that fails with "Sent holds 0 messages" without the fix. Full go test ./... (25 packages) with TEST_DATABASE_URL, go vet and lang-lint are clean.
134 lines
4.9 KiB
Go
134 lines
4.9 KiB
Go
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alexedwards/argon2id"
|
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
|
"github.com/google/uuid"
|
|
|
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
|
"lambdamail/protocols/internal/infrastructure/dkim"
|
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
|
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
|
|
|
gosmtp "github.com/emersion/go-smtp"
|
|
)
|
|
|
|
// TestSubmissionFilesTheSentCopy drives the real submission server. A client
|
|
// that sends over SMTP used to leave nothing in the sender's Sent folder: the
|
|
// mail was delivered, but the account showed no sent mail at all, and a client
|
|
// cannot fill the folder itself because IMAP APPEND is not implemented.
|
|
func TestSubmissionFilesTheSentCopy(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("e2e test skipped in -short mode")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
|
Port(15450).
|
|
RuntimePath(t.TempDir()).
|
|
StartTimeout(120 * time.Second))
|
|
if err := pg.Start(); err != nil {
|
|
t.Fatalf("start embedded postgres: %v", err)
|
|
}
|
|
defer pg.Stop()
|
|
|
|
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:15450/postgres?sslmode=disable")
|
|
if err != nil {
|
|
t.Fatalf("connect pool: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
applyMigrations(t, ctx, pool)
|
|
|
|
const (
|
|
address = "plataforma@sentcopy.test"
|
|
password = "correct horse battery staple"
|
|
)
|
|
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
|
|
domainID, mailboxID := uuid.New(), uuid.New()
|
|
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'sentcopy.test', 'sentcopy.test')`, domainID)
|
|
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'plataforma', $3, $4)`,
|
|
mailboxID, domainID, address, hash)
|
|
pool.Exec(ctx, `INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, 'INBOX', 'inbox'), ($1, 'Sent', 'sent')`, mailboxID)
|
|
|
|
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
|
|
submissionUC := appusecase.NewProcessOutboundEmailUseCase(postgres.NewAuthRepository(pool), postgres.NewOutboundRepository(pool), blobs, dkim.NewSigner(nil))
|
|
submissionUC.SetSentCopies(postgres.NewInboundMessageRepository(pool))
|
|
|
|
server := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
|
|
server.Domain = "mail.sentcopy.test"
|
|
server.AllowInsecureAuth = false
|
|
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{selfSignedTLSCert(t)}}
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
go server.Serve(ln)
|
|
|
|
submitMessage(t, ln.Addr().String(), address, password, "ana@remote.test")
|
|
submitMessage(t, ln.Addr().String(), address, password, "bruno@other.test")
|
|
|
|
var sent int
|
|
var subject string
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT COUNT(*), COALESCE(MAX(m.subject), '')
|
|
FROM email_messages m JOIN folders f ON f.id = m.folder_id
|
|
WHERE m.mailbox_id = $1 AND f.special_use = 'sent'`, mailboxID).Scan(&sent, &subject); err != nil {
|
|
t.Fatalf("count Sent messages: %v", err)
|
|
}
|
|
|
|
if sent != 2 {
|
|
t.Fatalf("Sent holds %d messages after two submissions, want 2", sent)
|
|
}
|
|
if subject != "e2e" {
|
|
t.Errorf("Sent copy subject = %q, want the submitted one", subject)
|
|
}
|
|
|
|
var unread, total int
|
|
if err := pool.QueryRow(ctx, `SELECT unread_count, total_count FROM folders WHERE mailbox_id = $1 AND special_use = 'sent'`, mailboxID).Scan(&unread, &total); err != nil {
|
|
t.Fatalf("read Sent counters: %v", err)
|
|
}
|
|
if unread != 0 {
|
|
t.Errorf("Sent advertises %d unread messages: the sender's own copies are not unread mail", unread)
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("Sent total_count = %d, want 2", total)
|
|
}
|
|
|
|
var inbox int
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM email_messages m JOIN folders f ON f.id = m.folder_id
|
|
WHERE m.mailbox_id = $1 AND f.special_use = 'inbox'`, mailboxID).Scan(&inbox); err != nil {
|
|
t.Fatalf("count INBOX messages: %v", err)
|
|
}
|
|
if inbox != 0 {
|
|
t.Errorf("INBOX holds %d messages, want 0: the copy belongs in Sent only", inbox)
|
|
}
|
|
|
|
// The delivery jobs and the Sent copy share one stored message per submission
|
|
var blobs2, copies int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(DISTINCT blob_id) FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&blobs2); err != nil {
|
|
t.Fatalf("count queued blobs: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT COUNT(DISTINCT m.blob_id) FROM email_messages m
|
|
JOIN outbound_jobs j ON j.blob_id = m.blob_id WHERE m.mailbox_id = $1`, mailboxID).Scan(&copies); err != nil {
|
|
t.Fatalf("count shared blobs: %v", err)
|
|
}
|
|
if copies != blobs2 {
|
|
t.Errorf("the Sent copies reference %d of the %d queued messages' blobs, want all", copies, blobs2)
|
|
}
|
|
}
|