Alerting: Handle placeholder email addresses gracefully in default contact point
This change prevents errors when using the default grafana-default-email contact point with its placeholder email address <example@email.com>. Changes include: - Skip sending emails to placeholder addresses without throwing errors - Show warning message in UI when testing contact points with placeholder emails - Filter out placeholder addresses when mixed with valid email addresses - Add unit tests for placeholder email detection and handling - Handle empty recipient lists gracefully in SMTP client The UI now displays a yellow warning box with a helpful message prompting users to configure a valid email address. Backend logs now show an informative message when skipping placeholder emails: INFO Skipping email notification to placeholder address(es). Please configure a valid email address in your contact point to receive alerts. logger=ngalert.notifier.sender addresses=[<example@email.com>]
This commit is contained in:
@@ -2,19 +2,52 @@ package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/alerting/receivers"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
)
|
||||
|
||||
const (
|
||||
// placeholderEmailAddress is the default placeholder email address used when no real email is configured
|
||||
placeholderEmailAddress = "<example@email.com>"
|
||||
)
|
||||
|
||||
var logger = log.New("ngalert.notifier.sender")
|
||||
|
||||
type emailSender struct {
|
||||
ns notifications.Service
|
||||
}
|
||||
|
||||
// isPlaceholderEmail checks if the given email address is a placeholder that should not be sent
|
||||
func isPlaceholderEmail(email string) bool {
|
||||
trimmed := strings.TrimSpace(email)
|
||||
return trimmed == placeholderEmailAddress || trimmed == "example@email.com"
|
||||
}
|
||||
|
||||
func (s emailSender) SendEmail(ctx context.Context, cmd *receivers.SendEmailSettings) error {
|
||||
// Filter out placeholder addresses from the recipient list (single loop)
|
||||
validRecipients := make([]string, 0, len(cmd.To))
|
||||
for _, addr := range cmd.To {
|
||||
if !isPlaceholderEmail(addr) {
|
||||
validRecipients = append(validRecipients, addr)
|
||||
} else {
|
||||
logger.Warn("Filtering out placeholder email address from recipients", "address", addr)
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid recipients remain, skip sending
|
||||
if len(validRecipients) == 0 {
|
||||
if len(cmd.To) > 0 {
|
||||
logger.Info("Skipping email notification to placeholder address(es). Please configure a valid email address in your contact point to receive alerts.", "addresses", cmd.To)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
sendEmailCommand := notifications.SendEmailCommand{
|
||||
To: cmd.To,
|
||||
To: validRecipients,
|
||||
SingleEmail: cmd.SingleEmail,
|
||||
Template: cmd.Template,
|
||||
Subject: cmd.Subject,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/alerting/receivers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func TestIsPlaceholderEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "placeholder with angle brackets",
|
||||
email: "<example@email.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "placeholder without angle brackets",
|
||||
email: "example@email.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "placeholder with spaces",
|
||||
email: " <example@email.com> ",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid email",
|
||||
email: "user@example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "another valid email",
|
||||
email: "admin@grafana.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
email: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isPlaceholderEmail(tt.email)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockNotificationService struct {
|
||||
sendEmailCalled bool
|
||||
lastCommand *notifications.SendEmailCommandSync
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendEmailCommandHandlerSync(ctx context.Context, cmd *notifications.SendEmailCommandSync) error {
|
||||
m.sendEmailCalled = true
|
||||
m.lastCommand = cmd
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendEmailCommandHandler(ctx context.Context, cmd *notifications.SendEmailCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendWebhookSync(ctx context.Context, cmd *notifications.SendWebhookSync) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendResetPasswordEmail(ctx context.Context, cmd *notifications.SendResetPasswordEmailCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) ValidateResetPasswordCode(ctx context.Context, query *notifications.ValidateResetPasswordCodeQuery, userByLogin notifications.GetUserByLoginFunc) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendVerificationEmail(ctx context.Context, cmd *notifications.SendVerifyEmailCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEmailSender_SendEmail_PlaceholderHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
expectSend bool
|
||||
expectedRecips []string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "all placeholder addresses - skip gracefully",
|
||||
recipients: []string{"<example@email.com>"},
|
||||
expectSend: false,
|
||||
expectedRecips: nil,
|
||||
description: "Should skip sending when all recipients are placeholders",
|
||||
},
|
||||
{
|
||||
name: "mixed valid and placeholder addresses",
|
||||
recipients: []string{"<example@email.com>", "user@example.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"user@example.com"},
|
||||
description: "Should filter out placeholders and send to valid addresses",
|
||||
},
|
||||
{
|
||||
name: "all valid addresses",
|
||||
recipients: []string{"user@example.com", "admin@grafana.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"user@example.com", "admin@grafana.com"},
|
||||
description: "Should send to all valid addresses",
|
||||
},
|
||||
{
|
||||
name: "multiple placeholders with one valid",
|
||||
recipients: []string{"example@email.com", "<example@email.com>", "valid@example.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"valid@example.com"},
|
||||
description: "Should filter out all placeholder variations and send to valid address",
|
||||
},
|
||||
{
|
||||
name: "only placeholders without angle brackets",
|
||||
recipients: []string{"example@email.com"},
|
||||
expectSend: false,
|
||||
expectedRecips: nil,
|
||||
description: "Should skip sending when placeholder is without angle brackets",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockNS := &mockNotificationService{}
|
||||
sender := emailSender{ns: mockNS}
|
||||
|
||||
cmd := &receivers.SendEmailSettings{
|
||||
To: tt.recipients,
|
||||
Subject: "Test Subject",
|
||||
SingleEmail: true,
|
||||
}
|
||||
|
||||
err := sender.SendEmail(context.Background(), cmd)
|
||||
require.NoError(t, err, tt.description)
|
||||
|
||||
if tt.expectSend {
|
||||
assert.True(t, mockNS.sendEmailCalled, "Expected email to be sent but it was not")
|
||||
assert.Equal(t, tt.expectedRecips, mockNS.lastCommand.SendEmailCommand.To, "Recipients mismatch")
|
||||
} else {
|
||||
assert.False(t, mockNS.sendEmailCalled, "Expected email not to be sent but it was")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSender_SendEmail_EmptyRecipients(t *testing.T) {
|
||||
mockNS := &mockNotificationService{}
|
||||
sender := emailSender{ns: mockNS}
|
||||
|
||||
cmd := &receivers.SendEmailSettings{
|
||||
To: []string{},
|
||||
Subject: "Test Subject",
|
||||
SingleEmail: true,
|
||||
}
|
||||
|
||||
err := sender.SendEmail(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, mockNS.sendEmailCalled, "Should not send email with empty recipient list")
|
||||
}
|
||||
|
||||
func TestEmailSender_SendEmail_EmbeddedContents(t *testing.T) {
|
||||
mockNS := &mockNotificationService{}
|
||||
sender := emailSender{ns: mockNS}
|
||||
|
||||
cmd := &receivers.SendEmailSettings{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Test with embedded content",
|
||||
SingleEmail: true,
|
||||
EmbeddedContents: []receivers.EmbeddedContent{
|
||||
{Name: "image.png", Content: []byte("fake image data")},
|
||||
},
|
||||
}
|
||||
|
||||
err := sender.SendEmail(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mockNS.sendEmailCalled, "Email should be sent")
|
||||
assert.Len(t, mockNS.lastCommand.SendEmailCommand.EmbeddedContents, 1, "Embedded content should be passed through")
|
||||
assert.Equal(t, "image.png", mockNS.lastCommand.SendEmailCommand.EmbeddedContents[0].Name)
|
||||
}
|
||||
@@ -74,6 +74,11 @@ func (sc *SmtpClient) sendMessage(ctx context.Context, dialer *gomail.Dialer, ms
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
// Skip sending if there are no recipients
|
||||
if len(msg.To) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := sc.buildEmail(ctx, msg)
|
||||
|
||||
err := dialer.DialAndSend(m)
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { locationService, logMeasurement } from '@grafana/runtime';
|
||||
import { AlertManagerCortexConfig, AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import {
|
||||
AlertManagerCortexConfig,
|
||||
AlertmanagerGroup,
|
||||
Matcher,
|
||||
Receiver,
|
||||
TestReceiversAlert,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { ThunkResult } from 'app/types/store';
|
||||
import { RuleIdentifier, RuleNamespace, StateHistoryItem } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
|
||||
import { deleteAlertManagerConfig, fetchAlertGroups, updateAlertManagerConfig } from '../api/alertmanager';
|
||||
import {
|
||||
deleteAlertManagerConfig,
|
||||
fetchAlertGroups,
|
||||
testReceivers,
|
||||
updateAlertManagerConfig,
|
||||
} from '../api/alertmanager';
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { fetchAnnotations } from '../api/annotations';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
@@ -253,6 +266,56 @@ export const deleteAlertManagerConfigAction = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
interface TestReceiversOptions {
|
||||
alertManagerSourceName: string;
|
||||
receivers: Receiver[];
|
||||
alert?: TestReceiversAlert;
|
||||
}
|
||||
|
||||
// Check if a receiver uses placeholder email addresses
|
||||
function hasPlaceholderEmail(receivers: Receiver[]): boolean {
|
||||
const placeholderEmails = ['<example@email.com>', 'example@email.com'];
|
||||
|
||||
return receivers.some((receiver) =>
|
||||
receiver.grafana_managed_receiver_configs?.some((config) => {
|
||||
if (config.type === 'email' && config.settings?.addresses) {
|
||||
const addresses = config.settings.addresses;
|
||||
// addresses can be a string or array
|
||||
const addressList = typeof addresses === 'string' ? [addresses] : addresses;
|
||||
return addressList.some((addr: string) => placeholderEmails.includes(addr.trim()));
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export const testReceiversAction = createAsyncThunk(
|
||||
'unifiedalerting/testReceivers',
|
||||
async ({ alertManagerSourceName, receivers, alert }: TestReceiversOptions): Promise<void> => {
|
||||
const usesPlaceholder = hasPlaceholderEmail(receivers);
|
||||
|
||||
if (usesPlaceholder) {
|
||||
// Handle placeholder email case with custom warning message
|
||||
try {
|
||||
await withSerializedError(testReceivers(alertManagerSourceName, receivers, alert));
|
||||
appEvents.emit(AppEvents.alertWarning, [
|
||||
'Test completed, but no email was sent because a placeholder email address is configured. Please update your contact point with a valid email address to receive alerts.',
|
||||
]);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||||
appEvents.emit(AppEvents.alertError, [`Failed to send test alert: ${msg}`]);
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return withAppEvents(withSerializedError(testReceivers(alertManagerSourceName, receivers, alert)), {
|
||||
errorMessage: 'Failed to send test alert.',
|
||||
successMessage: 'Test alert sent.',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
|
||||
return rules.filter((rule: RulerRuleDTO) => {
|
||||
const { forDuration } = getAlertInfo(rule, everyDuration);
|
||||
|
||||
Reference in New Issue
Block a user