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
9 changes: 6 additions & 3 deletions backend/internal/mail/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (


type MailService interface {
SendMail(ctx context.Context, address []string, body string, attatchments []domain.Attatchment) error
SendTemplateMail(ctx context.Context, address []string, template string, vars map[string]string, locale string) error
// SendMail sends body to `to` with optional cc recipients. cc may be nil/empty.
// subject may be empty (compliance-report legacy senders pass their subject
// text inside the body — new callers should pass a proper subject).
SendMail(ctx context.Context, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error
SendTemplateMail(ctx context.Context, to []string, template string, vars map[string]string, locale string) error
// SendMailWithConfig sends a message using the supplied EmailConfig instead
// of loading one from storage. Used by orchestration flows such as
// "test current mail settings" where the config under test is not persisted.
SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, address []string, body string, attatchments []domain.Attatchment) error
SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error
}
44 changes: 35 additions & 9 deletions backend/internal/mail/usecase/mail_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,42 @@ func New(repo connectors.MailConfigurationRepository) connectors.MailService {
return &mailService{repo: repo}
}

func (s *mailService) SendMail(ctx context.Context, address []string, body string, attatchments []domain.Attatchment) error {
func (s *mailService) SendMail(ctx context.Context, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error {
cfg, err := s.repo.GetMailConfiguration(ctx)
if err != nil {
return fmt.Errorf("load mail config: %w", err)
}
return s.SendMailWithConfig(ctx, cfg, address, body, attatchments)
return s.SendMailWithConfig(ctx, cfg, to, cc, subject, body, attatchments)
}

func (s *mailService) SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, address []string, body string, attatchments []domain.Attatchment) error {
func (s *mailService) SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error {
if cfg == nil {
return fmt.Errorf("mail configuration is nil")
}
if cfg.Host == "" || cfg.Port == "" {
return fmt.Errorf("mail configuration is incomplete")
}
if len(address) == 0 {
to = trimAddresses(to)
cc = trimAddresses(cc)
if len(to) == 0 {
return fmt.Errorf("no recipients")
}

msg, err := buildMessage(cfg, address, body, attatchments)
msg, err := buildMessage(cfg, to, cc, subject, body, attatchments)
if err != nil {
return err
}

addr := cfg.Host + ":" + cfg.Port
auth := smtpAuth(cfg)
from := senderAddress(cfg)
return smtp.SendMail(addr, auth, from, address, msg)
// net/smtp needs every recipient in the RCPT TO list — headers alone don't
// deliver mail. Merge to+cc for the envelope; the Cc header stays for display.
rcpt := append(append([]string(nil), to...), cc...)
return smtp.SendMail(addr, auth, from, rcpt, msg)
}

func (s *mailService) SendTemplateMail(ctx context.Context, address []string, tmpl string, vars map[string]string, locale string) error {
func (s *mailService) SendTemplateMail(ctx context.Context, to []string, tmpl string, vars map[string]string, locale string) error {
tpl, err := template.New("mail").Parse(tmpl)
if err != nil {
return fmt.Errorf("parse template: %w", err)
Expand All @@ -66,7 +71,7 @@ func (s *mailService) SendTemplateMail(ctx context.Context, address []string, tm
if err := tpl.Execute(&buf, data); err != nil {
return fmt.Errorf("render template: %w", err)
}
return s.SendMail(ctx, address, buf.String(), nil)
return s.SendMail(ctx, to, nil, "", buf.String(), nil)
}

func smtpAuth(cfg *domain.EmailConfig) smtp.Auth {
Expand All @@ -83,13 +88,34 @@ func senderAddress(cfg *domain.EmailConfig) string {
return cfg.Username
}

func buildMessage(cfg *domain.EmailConfig, to []string, body string, attatchments []domain.Attatchment) ([]byte, error) {
// trimAddresses drops empty strings from a recipient list without touching order.
func trimAddresses(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, a := range in {
a = strings.TrimSpace(a)
if a != "" {
out = append(out, a)
}
}
return out
}

func buildMessage(cfg *domain.EmailConfig, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) ([]byte, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)

headers := textproto.MIMEHeader{}
headers.Set("From", senderAddress(cfg))
headers.Set("To", strings.Join(to, ", "))
if len(cc) > 0 {
headers.Set("Cc", strings.Join(cc, ", "))
}
if subject != "" {
headers.Set("Subject", subject)
}
headers.Set("MIME-Version", "1.0")
headers.Set("Content-Type", "multipart/mixed; boundary="+writer.Boundary())
if cfg.Orgname != "" {
Expand Down
3 changes: 1 addition & 2 deletions backend/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import (
iam_repository "github.com/utmstack/utmstack/backend/modules/iam/repository"
iam_usecase "github.com/utmstack/utmstack/backend/modules/iam/usecase"
"github.com/utmstack/utmstack/backend/modules/incidents"
incidents_connectors "github.com/utmstack/utmstack/backend/modules/incidents/connectors"
"github.com/utmstack/utmstack/backend/modules/integrations"
"github.com/utmstack/utmstack/backend/modules/loganalyzer"
mcpmod "github.com/utmstack/utmstack/backend/modules/mcp"
Expand Down Expand Up @@ -188,7 +187,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
env.String("UPDATES_DIR", "/updates", false))
incidentsMod := incidents.NewModule(
db,
incidents_connectors.NewNoopMailer(),
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store(), userRepo),
incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()),
incidents.NewIAMGatewayFromRepo(userRepo),
auditMod.Logger(),
Expand Down
2 changes: 1 addition & 1 deletion backend/modules/appconfig/usecase/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func (s *service) CheckMail(ctx context.Context, configs []domain.MailConfig) er
}
}
ec := toEmailConfig(cfg)
if err := s.mailer.SendMailWithConfig(ctx, &ec, []string{cfg.From}, body, nil); err != nil {
if err := s.mailer.SendMailWithConfig(ctx, &ec, []string{cfg.From}, nil, "Mail configuration test", body, nil); err != nil {
return fmt.Errorf("config[%d] (%s:%d): %w", i, cfg.Host, cfg.Port, err)
}
}
Expand Down
3 changes: 2 additions & 1 deletion backend/modules/compliance/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ func (m *mailSender) SendComplianceReport(ctx context.Context, toEmail, subject
ContentType: "application/pdf",
Bytes: pdfData,
}
return m.svc.SendMail(ctx, []string{toEmail}, subject, []mail_domain.Attatchment{attachment})
body := fmt.Sprintf("<html><body><p>%s</p></body></html>", subject)
return m.svc.SendMail(ctx, []string{toEmail}, nil, subject, body, []mail_domain.Attatchment{attachment})
}

// interface assertions
Expand Down
133 changes: 133 additions & 0 deletions backend/modules/incidents/mailer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package incidents

import (
"context"
"fmt"
"html"
"strings"

"github.com/threatwinds/go-sdk/catcher"
mail_connectors "github.com/utmstack/utmstack/backend/internal/mail/connectors"
appconfig_connectors "github.com/utmstack/utmstack/backend/modules/appconfig/connectors"
iam_connectors "github.com/utmstack/utmstack/backend/modules/iam/connectors"
"github.com/utmstack/utmstack/backend/modules/incidents/connectors"
"github.com/utmstack/utmstack/backend/modules/incidents/domain"
)

// Config keys for the global alert/incident notification recipients. Both are
// comma-separated lists of email addresses; both are optional. See migration
// 000003_alert_notification_recipients.
const (
ConfigKeyNotificationTo = "utmstack.alerts.notification_to"
ConfigKeyNotificationCc = "utmstack.alerts.notification_cc"
)

// incidentMailer sends incident-created notifications. Recipients come from the
// two config keys above; when both are empty it falls back to every activated
// user.
type incidentMailer struct {
mail mail_connectors.MailService
store appconfig_connectors.Store
userRepo iam_connectors.UserRepository
}

// NewIncidentMailer wires the real mailer used at composition time.
func NewIncidentMailer(
mail mail_connectors.MailService,
store appconfig_connectors.Store,
userRepo iam_connectors.UserRepository,
) connectors.IncidentMailer {
return &incidentMailer{mail: mail, store: store, userRepo: userRepo}
}

func (m *incidentMailer) SendIncidentCreated(ctx context.Context, incident domain.UtmIncident) error {
if m.mail == nil {
catcher.Warn("incidents: mail service not configured — skipping notification", nil)
return nil
}
to, cc, err := m.resolveRecipients(ctx)
if err != nil {
return fmt.Errorf("resolve incident notification recipients: %w", err)
}
if len(to) == 0 {
catcher.Warn("incidents: no recipients available — skipping notification", nil)
return nil
}
subject, body := renderIncidentCreated(incident)
return m.mail.SendMail(ctx, to, cc, subject, body, nil)
}

// resolveRecipients returns (to, cc) resolved from config; when BOTH config
// keys are empty it falls back to every activated user's email as `to` and no
// cc.
func (m *incidentMailer) resolveRecipients(ctx context.Context) ([]string, []string, error) {
to := readList(ctx, m.store, ConfigKeyNotificationTo)
cc := readList(ctx, m.store, ConfigKeyNotificationCc)
if len(to) > 0 || len(cc) > 0 {
return to, cc, nil
}
if m.userRepo == nil {
return nil, nil, nil
}
// ponytail: single page, PageSize=200 (repo cap). Enough for every
// production tenant we've seen; paginate if a deployment exceeds it.
users, _, err := m.userRepo.List(ctx, iam_connectors.ListUsersFilter{PageSize: 200})
if err != nil {
return nil, nil, err
}
fallback := make([]string, 0, len(users))
for _, u := range users {
if !u.Activated || u.Email == "" {
continue
}
fallback = append(fallback, u.Email)
}
return fallback, nil, nil
}

// readList reads a config key and splits it on commas. Missing/empty → nil.
func readList(ctx context.Context, store appconfig_connectors.Store, key string) []string {
if store == nil {
return nil
}
v, ok, err := store.GetString(ctx, key)
if err != nil || !ok || strings.TrimSpace(v) == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

// renderIncidentCreated builds a subject + minimal HTML body for the notification.
func renderIncidentCreated(inc domain.UtmIncident) (subject, body string) {
subject = "New incident: " + inc.IncidentName

desc := ""
if inc.IncidentDescription != nil {
desc = *inc.IncidentDescription
}
sev := "unknown"
if inc.IncidentSeverity != nil {
sev = fmt.Sprintf("%d", *inc.IncidentSeverity)
}
body = fmt.Sprintf(
`<html><body>`+
`<h2>%s</h2>`+
`<p><strong>Severity:</strong> %s</p>`+
`<p><strong>Status:</strong> %s</p>`+
`<p>%s</p>`+
`</body></html>`,
html.EscapeString(inc.IncidentName),
html.EscapeString(sev),
html.EscapeString(inc.IncidentStatus),
html.EscapeString(desc),
)
return subject, body
}
106 changes: 106 additions & 0 deletions backend/modules/incidents/mailer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package incidents

import (
"context"
"reflect"
"testing"

appconfig_connectors "github.com/utmstack/utmstack/backend/modules/appconfig/connectors"
iam_connectors "github.com/utmstack/utmstack/backend/modules/iam/connectors"
iam_domain "github.com/utmstack/utmstack/backend/modules/iam/domain"
)

// stubStore is a tiny in-memory Store for testing the resolver's fallback path.
type stubStore struct{ values map[string]string }

func (s *stubStore) GetString(_ context.Context, key string) (string, bool, error) {
v, ok := s.values[key]
return v, ok, nil
}

func (s *stubStore) SetString(_ context.Context, _, _ string, _ appconfig_connectors.SetOpts) error {
return nil
}

// stubUserRepo returns a canned user list. Only List is exercised.
type stubUserRepo struct {
iam_connectors.UserRepository // embed so any unused method panics if called
users []iam_domain.User
}

func (s *stubUserRepo) List(_ context.Context, _ iam_connectors.ListUsersFilter) ([]iam_domain.User, int64, error) {
return s.users, int64(len(s.users)), nil
}

func TestResolveRecipients(t *testing.T) {
users := []iam_domain.User{
{Email: "a@x.com", Activated: true},
{Email: "b@x.com", Activated: false}, // deactivated → dropped
{Email: "", Activated: true}, // no email → dropped
{Email: "c@x.com", Activated: true},
}

cases := []struct {
name string
cfg map[string]string
users []iam_domain.User
wantTo []string
wantCc []string
}{
{
name: "explicit to and cc override user fallback",
cfg: map[string]string{ConfigKeyNotificationTo: "to1@x.com, to2@x.com", ConfigKeyNotificationCc: "cc1@x.com"},
users: users,
wantTo: []string{"to1@x.com", "to2@x.com"},
wantCc: []string{"cc1@x.com"},
},
{
name: "only cc set — no fallback",
cfg: map[string]string{ConfigKeyNotificationCc: "cc1@x.com"},
users: users,
wantTo: nil,
wantCc: []string{"cc1@x.com"},
},
{
name: "both empty → all activated users with non-empty email",
cfg: map[string]string{},
users: users,
wantTo: []string{"a@x.com", "c@x.com"},
wantCc: nil,
},
{
name: "both empty and no users → empty",
cfg: map[string]string{},
users: nil,
wantTo: []string{},
wantCc: nil,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := &incidentMailer{
store: &stubStore{values: tc.cfg},
userRepo: &stubUserRepo{users: tc.users},
}
to, cc, err := m.resolveRecipients(context.Background())
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
// Compare with normalised nil/empty: treat empty slice == nil for equality.
if !slicesEqual(to, tc.wantTo) {
t.Errorf("to: got %v want %v", to, tc.wantTo)
}
if !slicesEqual(cc, tc.wantCc) {
t.Errorf("cc: got %v want %v", cc, tc.wantCc)
}
})
}
}

func slicesEqual(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
return reflect.DeepEqual(a, b)
}
Loading
Loading