Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions email.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package notify

import (
"context"
"errors"
"fmt"
"net/mail"
"net/url"
Expand All @@ -23,7 +24,7 @@ type SMTPParams struct {
LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com
Username string // username
Password string // password
TimeOut time.Duration // TCP connection timeout
TimeOut time.Duration // TCP connection timeout, the rest of the transaction is bound by the context of Send
}

// Email notifications client
Expand Down Expand Up @@ -95,12 +96,13 @@ func (e *Email) Send(ctx context.Context, destination, text string) error {
return fmt.Errorf("problem parsing destination: %w", err)
}

select {
case <-ctx.Done():
return ctx.Err()
default:
return e.sender.Send(text, emailParams)
// SendContext terminates the transaction when ctx is done, including the parts after the connection is made
err = e.sender.SendContext(ctx, text, emailParams)
if err != nil && ctx.Err() != nil && !errors.Is(err, ctx.Err()) {
// transaction was interrupted, report why on top of the error it failed with
return fmt.Errorf("%w: %w", ctx.Err(), err)
}
return err
}

// Schema returns schema prefix supported by this client
Expand Down
66 changes: 66 additions & 0 deletions email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package notify

import (
"context"
"fmt"
"io"
"net"
"strconv"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -74,3 +79,64 @@ func TestEmailSendClientError(t *testing.T) {
cancel()
assert.EqualError(t, email.Send(ctx, "mailto:test@example.org", ""), "context canceled")
}

func TestEmail_SendCancellationAfterConnect(t *testing.T) {
// server accepts the connection, greets and stops responding
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)

var wg sync.WaitGroup
defer wg.Wait()
defer func() { _ = ln.Close() }()

var once sync.Once
greeted := make(chan struct{}) // closed once the client sent a command, i.e. it is past the connection and the greeting
wg.Go(func() {
for {
conn, e := ln.Accept()
if e != nil {
return
}
wg.Go(func() {
defer func() { _ = conn.Close() }()
// server side gives up on its own, so a regression fails the test instead of hanging the suite
_ = conn.SetDeadline(time.Now().Add(time.Second * 20))
_, _ = fmt.Fprint(conn, "220 localhost ESMTP stub\r\n")
buf := make([]byte, 1)
if _, e := conn.Read(buf); e == nil {
once.Do(func() { close(greeted) })
}
_, _ = io.Copy(io.Discard, conn)
})
}
})

host, portStr, err := net.SplitHostPort(ln.Addr().String())
require.NoError(t, err)
port, err := strconv.Atoi(portStr)
require.NoError(t, err)

// connection timeout is long on purpose, the context should terminate the send
email := NewEmail(SMTPParams{Host: host, Port: port, TimeOut: time.Minute})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// cancel only once the transaction is past the connection, otherwise the test proves nothing
go func() {
<-greeted
cancel()
}()
failsafe := time.AfterFunc(time.Second*30, cancel)
defer failsafe.Stop()

st := time.Now()
err = email.Send(ctx, "mailto:test@example.org?from=notify@example.org", "test")
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled, "cancellation is visible to the caller")
assert.Less(t, time.Since(st), time.Second*5, "send is terminated by the context, not by the stalled server")
select {
case <-greeted:
default:
t.Fatal("send was canceled before the connection was established, the test proves nothing")
}
}