Files
vikunja/pkg/mail/mail_test.go
kolaente 0b70fb2658 fix(cli): drain the mail queue before user commands exit
Mail sending is asynchronous: SendMail only enqueues the message and a
background daemon goroutine performs the actual SMTP delivery. CLI
commands exited as soon as their run function returned, killing the
daemon before the handshake completed, so `user reset-password`,
`user delete` and `user create` reported success without ever
sending their mail.

Add mail.StopMailDaemon which closes the queue and blocks until the
daemon has delivered all remaining messages (with a timeout so a broken
SMTP server can't hang the CLI), and call it from a PersistentPostRun
hook on the user command. The web server's non-blocking behavior is
unchanged.
2026-07-09 17:05:21 +00:00

164 lines
4.0 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mail
import (
"bufio"
"fmt"
"net"
"strings"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// startSMTPSink runs a minimal SMTP server which accepts everything and
// pushes each received DATA payload into the returned channel.
func startSMTPSink(t *testing.T) (port int, received chan string) {
t.Helper()
l, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = l.Close()
})
received = make(chan string, 100)
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer conn.Close()
r := bufio.NewReader(conn)
fmt.Fprintf(conn, "220 sink ESMTP\r\n")
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
fmt.Fprintf(conn, "250-sink\r\n250 8BITMIME\r\n")
case strings.HasPrefix(cmd, "DATA"):
fmt.Fprintf(conn, "354 go ahead\r\n")
var body strings.Builder
for {
dl, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
body.WriteString(dl)
}
received <- body.String()
fmt.Fprintf(conn, "250 ok\r\n")
case strings.HasPrefix(cmd, "QUIT"):
fmt.Fprintf(conn, "221 bye\r\n")
return
default:
fmt.Fprintf(conn, "250 ok\r\n")
}
}
}(conn)
}
}()
return l.Addr().(*net.TCPAddr).Port, received
}
func setupMailerConfig(t *testing.T, host string, port int) {
t.Helper()
config.InitDefaultConfig()
log.InitLogger()
config.MailerEnabled.Set(true)
config.MailerHost.Set(host)
config.MailerPort.Set(port)
config.MailerFromEmail.Set("mail@vikunja")
config.MailerQueueTimeout.Set(2)
wasUnderTest := isUnderTest
isUnderTest = false
t.Cleanup(func() {
isUnderTest = wasUnderTest
StopMailDaemon()
})
}
func TestStopMailDaemonDrainsQueue(t *testing.T) {
port, received := startSMTPSink(t)
setupMailerConfig(t, "127.0.0.1", port)
StartMailDaemon()
for i := range 3 {
SendMail(&Opts{
To: fmt.Sprintf("test%d@example.com", i),
Subject: fmt.Sprintf("Test %d", i),
Message: "Hello",
ContentType: ContentTypePlain,
})
}
StopMailDaemon()
assert.Len(t, received, 3, "expected all queued mails to be delivered before StopMailDaemon returns")
assert.Nil(t, Queue)
}
func TestStopMailDaemonMailerDisabled(t *testing.T) {
config.InitDefaultConfig()
config.MailerEnabled.Set(false)
StartMailDaemon()
assert.Nil(t, Queue)
assert.NotPanics(t, StopMailDaemon)
}
func TestStopMailDaemonNeverStarted(t *testing.T) {
setupMailerConfig(t, "", 587)
// Empty host: StartMailDaemon creates the queue but never starts the daemon goroutine.
StartMailDaemon()
assert.NotNil(t, Queue)
assert.NotPanics(t, StopMailDaemon)
assert.Nil(t, Queue)
}
func TestStopMailDaemonCalledTwice(t *testing.T) {
port, _ := startSMTPSink(t)
setupMailerConfig(t, "127.0.0.1", port)
StartMailDaemon()
assert.NotPanics(t, StopMailDaemon)
assert.NotPanics(t, StopMailDaemon)
}