Compare commits

...

4 Commits

Author SHA1 Message Date
tonypowa
de1c2ef28e Fix: Allow example@email.com without angle brackets in contact points
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-11-06 13:29:01 +01:00
tonypowa
e51fb87cc2 Alerting: Refactor placeholder email detection into shared utility
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
- Extract duplicate placeholder email detection logic into reusable functions
- Add hasPlaceholderEmail() for single channel checks
- Add receiverHasPlaceholderEmail() for receiver list checks
- Centralize PLACEHOLDER_EMAILS constant in receiver-form.ts
- Update ChannelSubForm, TestContactPointModal, and actions to use utilities
- Reduces code duplication by 44 lines
2025-11-04 10:22:09 +01:00
tonypowa
7ba340f4a2 Alerting: Add frontend validation for placeholder email addresses
- Disable test button when placeholder email is detected
- Show info alert guiding users to configure valid email address
- Add tooltip explaining why test is disabled
- Pass channel values to TestContactPointModal for validation
- Proactively prevent testing with placeholder addresses
- Non-email contact points remain unaffected
2025-11-03 17:19:35 +01:00
tonypowa
08f58b2346 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>]
2025-11-03 17:02:29 +01:00
8 changed files with 399 additions and 8 deletions

View File

@@ -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
}
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,

View File

@@ -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: "not a placeholder - example@email.com without angle brackets",
email: "example@email.com",
expected: false,
},
{
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: "placeholder with angle brackets filtered, others sent",
recipients: []string{"example@email.com", "<example@email.com>", "valid@example.com"},
expectSend: true,
expectedRecips: []string{"example@email.com", "valid@example.com"},
description: "Should filter out only placeholder with angle brackets and send to other addresses",
},
{
name: "example@email.com without angle brackets is valid",
recipients: []string{"example@email.com"},
expectSend: true,
expectedRecips: []string{"example@email.com"},
description: "Should send to example@email.com when it doesn't have 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)
}

View File

@@ -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)

View File

@@ -16,6 +16,7 @@ import {
GrafanaChannelValues,
ReceiverFormValues,
} from '../../../types/receiver-form';
import { hasPlaceholderEmail } from '../../../utils/receiver-form';
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
import { ChannelOptions } from './ChannelOptions';
@@ -70,6 +71,10 @@ export function ChannelSubForm<R extends ChannelValues>({
const onCallIntegrationType = watch(`${settingsFieldPath}.integration_type`);
const isTestAvailable = onCallIntegrationType !== OnCallIntegrationType.NewIntegration;
// Check if email integration has placeholder addresses
const channelValues = getValues(channelFieldPath) as GrafanaChannelValues | undefined;
const isPlaceholderEmail = hasPlaceholderEmail(channelValues);
useEffect(() => {
register(`${channelFieldPath}.__id`);
/* Need to manually register secureFields or else they'll
@@ -230,7 +235,22 @@ export function ChannelSubForm<R extends ChannelValues>({
</div>
<div className={styles.buttons}>
{isTestable && onTest && isTestAvailable && (
<Button size="xs" variant="secondary" type="button" onClick={() => handleTest()} icon="message">
<Button
disabled={isPlaceholderEmail}
size="xs"
variant="secondary"
type="button"
onClick={() => handleTest()}
icon="message"
tooltip={
isPlaceholderEmail
? t(
'alerting.channel-sub-form.test-disabled-placeholder',
'Please configure a valid email address before testing'
)
: undefined
}
>
<Trans i18nKey="alerting.channel-sub-form.test">Test</Trans>
</Button>
)}
@@ -257,6 +277,20 @@ export function ChannelSubForm<R extends ChannelValues>({
</div>
{notifier && (
<div className={styles.innerContent}>
{isPlaceholderEmail && (
<Alert
title={t(
'alerting.contact-points.email.placeholder-warning-title',
'Configure a valid email address'
)}
severity="info"
>
<Trans i18nKey="alerting.contact-points.email.placeholder-warning-body">
This contact point is using a placeholder email address (<Text variant="code">example@email.com</Text>
). Please update it with a valid email address to receive alerts and enable testing.
</Trans>
</Alert>
)}
{showTelegramWarning && (
<Alert
title={t(

View File

@@ -185,6 +185,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
isOpen={!!testReceivers}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
receivers={testReceivers}
channelValues={testReceivers[0]?.grafana_managed_receiver_configs?.[0]}
/>
)}
</>

View File

@@ -4,13 +4,15 @@ import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Label, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { Alert, Button, Label, Modal, RadioButtonGroup, Text, useStyles2 } from '@grafana/ui';
import { Receiver, TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
import { Annotations, Labels } from 'app/types/unified-alerting-dto';
import { useTestIntegrationMutation } from '../../../api/receiversApi';
import { GrafanaChannelValues } from '../../../types/receiver-form';
import { defaultAnnotations } from '../../../utils/constants';
import { stringifyErrorLike } from '../../../utils/misc';
import { hasPlaceholderEmail } from '../../../utils/receiver-form';
import AnnotationsStep from '../../rule-editor/AnnotationsStep';
import LabelsField from '../../rule-editor/labels/LabelsField';
@@ -19,6 +21,7 @@ interface Props {
onDismiss: () => void;
alertManagerSourceName: string;
receivers: Receiver[];
channelValues?: GrafanaChannelValues;
}
type AnnoField = {
@@ -43,15 +46,23 @@ const defaultValues: FormFields = {
labels: [{ key: '', value: '' }],
};
export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceName, receivers }: Props) => {
export const TestContactPointModal = ({
isOpen,
onDismiss,
alertManagerSourceName,
receivers,
channelValues,
}: Props) => {
const [notificationType, setNotificationType] = useState<NotificationType>(NotificationType.predefined);
const styles = useStyles2(getStyles);
const formMethods = useForm<FormFields>({ defaultValues, mode: 'onBlur' });
const [testIntegration, { isLoading, error, isSuccess }] = useTestIntegrationMutation();
// Check if email integration has placeholder addresses
const isPlaceholderEmail = hasPlaceholderEmail(channelValues);
const onSubmit = async (data: FormFields) => {
let alert: TestReceiversAlert | undefined;
if (notificationType === NotificationType.custom) {
alert = {
annotations: data.annotations
@@ -93,6 +104,22 @@ export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceNam
/>
)}
{isPlaceholderEmail && (
<div className={styles.section}>
<Alert
title={t(
'alerting.test-contact-point-modal.placeholder-warning-title',
'Configure a valid email address'
)}
severity="info"
>
<Trans i18nKey="alerting.test-contact-point-modal.placeholder-warning-body">
This contact point is using a placeholder email address (<Text variant="code">example@email.com</Text>).
Please update it with a valid email address before testing.
</Trans>
</Alert>
</div>
)}
<div className={styles.section}>
<Label>
<Trans i18nKey="alerting.test-contact-point-modal.notification-message">Notification message</Trans>
@@ -132,7 +159,18 @@ export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceNam
)}
<Modal.ButtonRow>
<Button type="submit" disabled={isLoading}>
<Button
type="submit"
disabled={isLoading || isPlaceholderEmail}
tooltip={
isPlaceholderEmail
? t(
'alerting.test-contact-point-modal.test-disabled-placeholder',
'Please configure a valid email address before testing'
)
: undefined
}
>
<Trans i18nKey="alerting.test-contact-point-modal.send-test-notification">Send test notification</Trans>
</Button>
</Modal.ButtonRow>

View File

@@ -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';
@@ -17,6 +30,7 @@ import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
import { getAllRulesSourceNames } from '../utils/datasource';
import { makeAMLink } from '../utils/misc';
import { receiverHasPlaceholderEmail } from '../utils/receiver-form';
import { withAppEvents, withSerializedError } from '../utils/redux';
import { getAlertInfo } from '../utils/rules';
import { safeParsePrometheusDuration } from '../utils/time';
@@ -253,6 +267,39 @@ export const deleteAlertManagerConfigAction = createAsyncThunk(
}
);
interface TestReceiversOptions {
alertManagerSourceName: string;
receivers: Receiver[];
alert?: TestReceiversAlert;
}
export const testReceiversAction = createAsyncThunk(
'unifiedalerting/testReceivers',
async ({ alertManagerSourceName, receivers, alert }: TestReceiversOptions): Promise<void> => {
const usesPlaceholder = receiverHasPlaceholderEmail(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);

View File

@@ -304,3 +304,42 @@ export function omitTemporaryIdentifiers<T>(object: Readonly<T>): T {
return objectCopy;
}
/**
* Placeholder emails that ship with the default grafana-default-email contact point.
* These should not trigger actual email sends or throw errors.
*/
const PLACEHOLDER_EMAILS = ['<example@email.com>'];
/**
* Check if a single channel/integration has placeholder email addresses.
* Used in UI components to disable test buttons and show warnings.
*/
export function hasPlaceholderEmail(channelValues?: GrafanaChannelValues): boolean {
if (!channelValues || channelValues.type !== 'email') {
return false;
}
const addresses = channelValues.settings?.addresses;
if (!addresses) {
return false;
}
const addressList = typeof addresses === 'string' ? [addresses] : addresses;
return addressList.some((addr: string) => PLACEHOLDER_EMAILS.includes(addr?.trim()));
}
/**
* Check if any receiver in a list has placeholder email addresses.
* Used in actions to determine if warning messages should be shown.
*/
export function receiverHasPlaceholderEmail(receivers: Receiver[]): boolean {
return receivers.some((receiver) =>
receiver.grafana_managed_receiver_configs?.some((config) => {
if (config.type === 'email' && config.settings?.addresses) {
const addresses = config.settings.addresses;
const addressList = typeof addresses === 'string' ? [addresses] : addresses;
return addressList.some((addr: string) => PLACEHOLDER_EMAILS.includes(addr.trim()));
}
return false;
})
);
}