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
6 changes: 3 additions & 3 deletions internal/app/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ heartbeats:

assert.True(t, waitForLog(t, &buf, `"from":"active","to":"grace"`, 2*time.Second), "expected Active → Grace")
assert.True(t, waitForLog(t, &buf, `"from":"grace","to":"missing"`, 2*time.Second), "expected Grace → Missing")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":1`, 2*time.Second), "expected retry 1")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":2`, 2*time.Second), "expected retry 2")
assert.True(t, waitForLog(t, &buf, `notification failed after 2 retries`, 2*time.Second), "expected failure log")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":1`, 5*time.Second), "expected retry 1")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":2`, 5*time.Second), "expected retry 2")
assert.True(t, waitForLog(t, &buf, `notification failed after 2 retries`, 5*time.Second), "expected failure log")
})
}
20 changes: 20 additions & 0 deletions pkg/notify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The `notify` package provides pluggable implementations for sending notification
- Incoming webhook
- Graph API (channel messages)
- Configurable headers, TLS settings, and authentication
- Typed error semantics for transient vs permanent failures
- Shared HTTP client defaults and per-request timeouts

## Package Overview

Expand All @@ -28,6 +30,24 @@ The `notify` package provides pluggable implementations for sending notification
- [`msteams`](./msteams.go) – Sends cards via Microsoft Teams webhook
- [`msteamsgraph`](./msteamspgrahapi.go) – Sends rich messages using Graph API
- [`utils`](./utils.go) – Shared HTTP client abstraction
- [`utils/errors.go`](./utils/errors.go) – Typed error helpers (`transient` vs `permanent`)

## Error Semantics

All HTTP-based notifiers return typed errors via `utils.Wrap`:

- `transient`: safe to retry (timeouts, 5xx, 429, 408, network errors)
- `permanent`: do not retry (4xx, invalid payloads, auth errors)

Use `utils.IsTransient(err)` to decide whether to retry.

## Defaults & Timeouts

HTTP clients share a default per-request timeout of 10s. You can override it via:

- `slack.WithTimeout(...)`
- `msteams.WithTimeout(...)`
- `msteamsgraph.WithTimeout(...)`

## Usage

Expand Down
76 changes: 58 additions & 18 deletions pkg/notify/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import (
"crypto/tls"
"fmt"
"io"
"net"
"net/smtp"
"strings"
"time"

"github.com/containeroo/heartbeats/pkg/notify/utils"
)

// Sender defines an interface for sending email messages.
Expand Down Expand Up @@ -35,6 +39,11 @@ type Dialer interface {
Dial(addr string) (Client, error)
}

// ContextDialer establishes SMTP connections with a context.
type ContextDialer interface {
DialContext(ctx context.Context, addr string) (Client, error)
}

// Client represents a low-level connection to an SMTP server.
type Client interface {
Mail(from string) error
Expand All @@ -57,7 +66,19 @@ type DefaultDialer struct{}
// - Client: An active SMTP connection.
// - error: If the connection fails.
func (DefaultDialer) Dial(addr string) (Client, error) {
return smtp.Dial(addr)
return dialSMTP(context.Background(), addr, utils.DefaultTimeout)
}

// DialContext connects to the SMTP server with a context.
func (DefaultDialer) DialContext(ctx context.Context, addr string) (Client, error) {
timeout := utils.DefaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
if timeout <= 0 {
return nil, ctx.Err()
}
}
return dialSMTP(ctx, addr, timeout)
}

// MailClient sends email messages using SMTP.
Expand Down Expand Up @@ -92,9 +113,15 @@ func (c *MailClient) Send(ctx context.Context, msg Message) error {
raw := buildMIMEMessage(msg, c.SMTPConfig.From)
addr := fmt.Sprintf("%s:%d", c.SMTPConfig.Host, c.SMTPConfig.Port)

conn, err := c.Dialer.Dial(addr)
var conn Client
var err error
if d, ok := c.Dialer.(ContextDialer); ok {
conn, err = d.DialContext(ctx, addr)
} else {
conn, err = c.Dialer.Dial(addr)
}
if err != nil {
return fmt.Errorf("connect SMTP: %w", err)
return utils.Wrap(utils.ErrorTransient, "smtp connect", err)
}
defer conn.Close() // nolint:errcheck

Expand All @@ -104,39 +131,52 @@ func (c *MailClient) Send(ctx context.Context, msg Message) error {
InsecureSkipVerify: c.SMTPConfig.SkipInsecureVerify != nil && *c.SMTPConfig.SkipInsecureVerify,
}
if err := conn.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("start TLS: %w", err)
return utils.Wrap(utils.ErrorTransient, "smtp starttls", err)
}
}

if c.SMTPConfig.Username != "" {
auth := smtp.PlainAuth("", c.SMTPConfig.Username, c.SMTPConfig.Password, c.SMTPConfig.Host)
if err := conn.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth: %w", err)
return utils.Wrap(utils.ErrorPermanent, "smtp auth", err)
}
}

if err := conn.Mail(c.SMTPConfig.From); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
return utils.Wrap(utils.ErrorPermanent, "smtp mail from", err)
}
for _, to := range msg.To {
if err := conn.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO (%s): %w", to, err)
return utils.Wrap(utils.ErrorPermanent, "smtp rcpt", fmt.Errorf("%s: %w", to, err))
}
}

wc, err := conn.Data()
if err != nil {
return fmt.Errorf("DATA open: %w", err)
return utils.Wrap(utils.ErrorTransient, "smtp data", err)
}
defer wc.Close() // nolint:errcheck

if _, err := wc.Write(raw); err != nil {
return fmt.Errorf("write body: %w", err)
return utils.Wrap(utils.ErrorTransient, "smtp write", err)
}

return nil
}

func dialSMTP(ctx context.Context, addr string, timeout time.Duration) (Client, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
d := net.Dialer{Timeout: timeout}
netConn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
return smtp.NewClient(netConn, host)
}

// SMTPConfig contains connection and authentication settings for an SMTP server.
type SMTPConfig struct {
Host string `yaml:"host,omitempty"` // SMTP server hostname.
Expand Down Expand Up @@ -187,24 +227,24 @@ func buildMIMEMessage(msg Message, from string) []byte {

var buf strings.Builder
for k, v := range headers {
buf.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
}

buf.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
fmt.Fprintf(&buf, "\r\n--%s\r\n", boundary)
if msg.IsHTML {
buf.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
fmt.Fprintf(&buf, "Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
} else {
buf.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n\r\n")
fmt.Fprintf(&buf, "Content-Type: text/plain; charset=\"UTF-8\"\r\n\r\n")
}
buf.WriteString(msg.Body + "\r\n")

for _, att := range msg.Attachments {
buf.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
buf.WriteString(fmt.Sprintf("Content-Type: application/octet-stream; name=\"%s\"\r\n", att.Filename))
buf.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
buf.WriteString(string(att.Data) + "\r\n")
fmt.Fprintf(&buf, "\r\n--%s\r\n", boundary)
fmt.Fprintf(&buf, "Content-Type: application/octet-stream; name=\"%s\"\r\n", att.Filename)
fmt.Fprintf(&buf, "Content-Transfer-Encoding: base64\r\n\r\n")
fmt.Fprintf(&buf, "%s\r\n", string(att.Data))
}

buf.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
fmt.Fprintf(&buf, "\r\n--%s--\r\n", boundary)
return []byte(buf.String())
}
51 changes: 51 additions & 0 deletions pkg/notify/msteams/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package msteams

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestClient_Send_Integration(t *testing.T) {
t.Parallel()

var captured MSTeams
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() // nolint:errcheck
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &captured)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(srv.Close)

client := New()
_, err := client.Send(context.Background(), MSTeams{
Title: "deploy",
Text: "done",
}, srv.URL)

assert.NoError(t, err)
assert.Equal(t, "deploy", captured.Title)
assert.Equal(t, "done", captured.Text)
}

func TestClient_Send_Integration_Non200(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("bad"))
}))
t.Cleanup(srv.Close)

client := New()
_, err := client.Send(context.Background(), MSTeams{}, srv.URL)

assert.EqualError(t, err, "permanent: msteams http status: 400: bad")
}
20 changes: 15 additions & 5 deletions pkg/notify/msteams/msteams.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"time"

"github.com/containeroo/heartbeats/pkg/notify/utils"
)
Expand Down Expand Up @@ -53,6 +53,15 @@ func WithInsecureTLS(skipInsecure bool) Option {
}
}

// WithTimeout sets the per-request timeout.
func WithTimeout(timeout time.Duration) Option {
return func(c *Client) {
if hc, ok := c.HttpClient.(*utils.HttpClient); ok {
hc.Timeout = timeout
}
}
}

// New creates a new MS Teams client with functional options.
//
// Use options like WithHeaders or WithInsecureTLS to customize behavior.
Expand Down Expand Up @@ -80,22 +89,23 @@ func (c *Client) Send(ctx context.Context, message MSTeams, webhookURL string) (
// Serialize the message to JSON.
data, err := json.Marshal(message)
if err != nil {
return "", fmt.Errorf("error marshalling MS Teams message: %w", err)
return "", utils.Wrap(utils.ErrorPermanent, "msteams marshal", err)
}

// Execute the POST request using the configured HTTP client.
resp, err := c.HttpClient.DoRequest(ctx, "POST", webhookURL, data)
if err != nil {
return "", fmt.Errorf("error sending HTTP request: %w", err)
return "", utils.Wrap(utils.ErrorTransient, "msteams request", err)
}
defer resp.Body.Close() // nolint:errcheck

// Read response body regardless of status
body, _ := io.ReadAll(resp.Body)
body, _ := utils.ReadBodyLimited(resp.Body)
bodyStr := utils.RedactSecrets(string(body))

// Verify the HTTP status code is 200 OK.
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("received non-200 response: %d, body: %s", resp.StatusCode, string(body))
return "", utils.Wrap(utils.KindFromStatus(resp.StatusCode), "msteams http status", fmt.Errorf("%d: %s", resp.StatusCode, bodyStr))
}

return "Message sent successfully", nil
Expand Down
4 changes: 2 additions & 2 deletions pkg/notify/msteams/msteams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestClient_Send_RequestFailure(t *testing.T) {
_, err := client.Send(context.Background(), MSTeams{}, "https://webhook")

assert.Error(t, err)
assert.EqualError(t, err, "error sending HTTP request: unexpected EOF")
assert.EqualError(t, err, "transient: msteams request: unexpected EOF")
}

func TestClient_Send_Non200Status(t *testing.T) {
Expand All @@ -72,5 +72,5 @@ func TestClient_Send_Non200Status(t *testing.T) {
_, err := client.Send(context.Background(), MSTeams{}, "https://webhook")

assert.Error(t, err)
assert.EqualError(t, err, "received non-200 response: 403, body: forbidden")
assert.EqualError(t, err, "permanent: msteams http status: 403: forbidden")
}
50 changes: 50 additions & 0 deletions pkg/notify/msteamsgraph/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package msteamsgraph

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestClient_SendChannel_Integration(t *testing.T) {
t.Parallel()

var captured Message
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() // nolint:errcheck
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &captured)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"123"}`))
}))
t.Cleanup(srv.Close)

client := New(WithEndpointBase(srv.URL))
_, err := client.SendChannel(context.Background(), "team", "channel", Message{
Body: ItemBody{ContentType: "html", Content: "hi"},
})

assert.NoError(t, err)
assert.Equal(t, "html", captured.Body.ContentType)
assert.Equal(t, "hi", captured.Body.Content)
}

func TestClient_SendChannel_Integration_Non200(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"code":"BadRequest","message":"nope"}}`))
}))
t.Cleanup(srv.Close)

client := New(WithEndpointBase(srv.URL))
_, err := client.SendChannel(context.Background(), "team", "channel", Message{})

assert.EqualError(t, err, "permanent: msteamsgraph http status: BadRequest: nope")
}
Loading