feat(alert/report): Added optional CC and BCC fields for email notifi… (#29088)

Co-authored-by: Sivarajan Narayanan <sivarajannarayanan@Sivarajans-MacBook-Pro.local>
Co-authored-by: Sivarajan Narayanan <narayanan_sivarajan@apple.com>
This commit is contained in:
nsivarajan 2024-07-22 23:03:47 +05:30 committed by GitHub
parent 2a9a1d3194
commit 27dde2a811
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 364 additions and 36 deletions

View File

@ -98,6 +98,7 @@ export interface AlertReportModalProps {
const DEFAULT_WORKING_TIMEOUT = 3600; const DEFAULT_WORKING_TIMEOUT = 3600;
const DEFAULT_CRON_VALUE = '0 0 * * *'; // every day const DEFAULT_CRON_VALUE = '0 0 * * *'; // every day
const DEFAULT_RETENTION = 90; const DEFAULT_RETENTION = 90;
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = [ const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = [
NotificationMethodOption.Email, NotificationMethodOption.Email,
@ -372,6 +373,7 @@ export const TRANSLATIONS = {
WORKING_TIMEOUT_ERROR_TEXT: t('working timeout'), WORKING_TIMEOUT_ERROR_TEXT: t('working timeout'),
RECIPIENTS_ERROR_TEXT: t('recipients'), RECIPIENTS_ERROR_TEXT: t('recipients'),
EMAIL_SUBJECT_ERROR_TEXT: t('email subject'), EMAIL_SUBJECT_ERROR_TEXT: t('email subject'),
EMAIL_VALIDATION_ERROR_TEXT: t('invalid email'),
ERROR_TOOLTIP_MESSAGE: t( ERROR_TOOLTIP_MESSAGE: t(
'Not all required fields are complete. Please provide the following:', 'Not all required fields are complete. Please provide the following:',
), ),
@ -621,6 +623,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
recipients.push({ recipients.push({
recipient_config_json: { recipient_config_json: {
target: setting.recipients, target: setting.recipients,
ccTarget: setting.cc,
bccTarget: setting.bcc,
}, },
type: setting.method, type: setting.method,
}); });
@ -1014,6 +1018,31 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
return hasInfo; return hasInfo;
}; };
const checkEmailFormat = () => {
if (!notificationSettings.length) {
return true;
}
const validateEmails = (emails: string): boolean => {
if (!emails) return true; // No emails to validate
return emails
.split(/[,;]/)
.every(email => EMAIL_REGEX.test(email.trim()));
};
// Use array method to check conditions
return notificationSettings.every(setting => {
if (!!setting.method && setting.method === 'Email') {
return (
(!setting.recipients?.length || validateEmails(setting.recipients)) &&
(!setting.cc || validateEmails(setting.cc)) &&
(!setting.bcc || validateEmails(setting.bcc))
);
}
return true; // Non-Email methods are considered valid
});
};
const validateGeneralSection = () => { const validateGeneralSection = () => {
const errors = []; const errors = [];
if (!currentAlert?.name?.length) { if (!currentAlert?.name?.length) {
@ -1069,13 +1098,24 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
}; };
const validateNotificationSection = () => { const validateNotificationSection = () => {
const errors = [];
const hasErrors = !checkNotificationSettings(); const hasErrors = !checkNotificationSettings();
const errors = hasErrors ? [TRANSLATIONS.RECIPIENTS_ERROR_TEXT] : [];
if (hasErrors) {
errors.push(TRANSLATIONS.RECIPIENTS_ERROR_TEXT);
} else {
// Check for email format errors
const hasValidationErrors = !checkEmailFormat();
if (hasValidationErrors) {
errors.push(TRANSLATIONS.EMAIL_VALIDATION_ERROR_TEXT);
}
}
if (emailError) { if (emailError) {
errors.push(TRANSLATIONS.EMAIL_SUBJECT_ERROR_TEXT); errors.push(TRANSLATIONS.EMAIL_SUBJECT_ERROR_TEXT);
} }
// Update validation status with combined errors
updateValidationStatus(Sections.Notification, errors); updateValidationStatus(Sections.Notification, errors);
}; };
@ -1132,6 +1172,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
setNotificationSettings([ setNotificationSettings([
{ {
recipients: '', recipients: '',
cc: '',
bcc: '',
options: allowedNotificationMethods, options: allowedNotificationMethods,
method: NotificationMethodOption.Email, method: NotificationMethodOption.Email,
}, },
@ -1153,6 +1195,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
// @ts-ignore: Type not assignable // @ts-ignore: Type not assignable
recipients: config.target || setting.recipient_config_json, recipients: config.target || setting.recipient_config_json,
options: allowedNotificationMethods, options: allowedNotificationMethods,
cc: config.ccTarget || '',
bcc: config.bccTarget || '',
}; };
}); });

View File

@ -80,8 +80,8 @@ describe('NotificationMethod', () => {
/>, />,
); );
const deleteButton = screen.getByRole('button'); const deleteButton = document.querySelector('.delete-button');
userEvent.click(deleteButton); if (deleteButton) userEvent.click(deleteButton);
expect(mockOnRemove).toHaveBeenCalledWith(1); expect(mockOnRemove).toHaveBeenCalledWith(1);
}); });

View File

@ -44,34 +44,75 @@ import {
import { StyledInputContainer } from '../AlertReportModal'; import { StyledInputContainer } from '../AlertReportModal';
const StyledNotificationMethod = styled.div` const StyledNotificationMethod = styled.div`
margin-bottom: 10px; ${({ theme }) => `
margin-bottom: ${theme.gridUnit * 3}px;
.input-container { .input-container {
textarea { textarea {
height: auto; height: auto;
} }
&.error { &.error {
input { input {
border-color: ${({ theme }) => theme.colors.error.base}; border-color: ${theme.colors.error.base};
}
}
.helper {
margin-top: ${theme.gridUnit * 2}px;
font-size: ${theme.typography.sizes.s}px;
color: ${theme.colors.grayscale.base};
} }
} }
}
.inline-container { .inline-container {
margin-bottom: 10px; margin-bottom: ${theme.gridUnit * 2}px;
> div { > div {
margin: 0; margin: 0px;
}
.delete-button {
margin-left: ${theme.gridUnit * 2}px;
padding-top: ${theme.gridUnit}px;
}
} }
.delete-button { .ghost-button {
margin-left: 10px; color: ${theme.colors.primary.dark1};
padding-top: 3px; display: inline-flex;
align-items: center;
font-size: ${theme.typography.sizes.s}px;
cursor: pointer;
margin-top: ${theme.gridUnit}px;
.icon {
width: ${theme.gridUnit * 3}px;
height: ${theme.gridUnit * 3}px;
font-size: ${theme.typography.sizes.s}px;
margin-right: ${theme.gridUnit}px;
}
} }
}
.ghost-button + .ghost-button {
margin-left: ${theme.gridUnit * 4}px;
}
.ghost-button:first-child[style*='none'] + .ghost-button {
margin-left: 0px; /* Remove margin when the first button is hidden */
}
`}
`; `;
const TRANSLATIONS = {
EMAIL_CC_NAME: t('CC recipients'),
EMAIL_BCC_NAME: t('BCC recipients'),
EMAIL_SUBJECT_NAME: t('Email subject name (optional)'),
EMAIL_SUBJECT_ERROR_TEXT: t(
'Please enter valid text. Spaces alone are not permitted.',
),
};
interface NotificationMethodProps { interface NotificationMethodProps {
setting?: NotificationSetting | null; setting?: NotificationSetting | null;
index: number; index: number;
@ -85,13 +126,6 @@ interface NotificationMethodProps {
setErrorSubject: (hasError: boolean) => void; setErrorSubject: (hasError: boolean) => void;
} }
const TRANSLATIONS = {
EMAIL_SUBJECT_NAME: t('Email subject name (optional)'),
EMAIL_SUBJECT_ERROR_TEXT: t(
'Please enter valid text. Spaces alone are not permitted.',
),
};
export const mapSlackValues = ({ export const mapSlackValues = ({
method, method,
recipientValue, recipientValue,
@ -164,7 +198,7 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
defaultSubject, defaultSubject,
setErrorSubject, setErrorSubject,
}) => { }) => {
const { method, recipients, options } = setting || {}; const { method, recipients, cc, bcc, options } = setting || {};
const [recipientValue, setRecipientValue] = useState<string>( const [recipientValue, setRecipientValue] = useState<string>(
recipients || '', recipients || '',
); );
@ -172,6 +206,10 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
{ label: string; value: string }[] { label: string; value: string }[]
>([]); >([]);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [ccVisible, setCcVisible] = useState<boolean>(!!cc);
const [bccVisible, setBccVisible] = useState<boolean>(!!bcc);
const [ccValue, setCcValue] = useState<string>(cc || '');
const [bccValue, setBccValue] = useState<string>(bcc || '');
const theme = useTheme(); const theme = useTheme();
const [slackOptions, setSlackOptions] = useState<SlackOptionsType>([ const [slackOptions, setSlackOptions] = useState<SlackOptionsType>([
{ {
@ -188,11 +226,16 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
}) => { }) => {
// Since we're swapping the method, reset the recipients // Since we're swapping the method, reset the recipients
setRecipientValue(''); setRecipientValue('');
setCcValue('');
setBccValue('');
if (onUpdate && setting) { if (onUpdate && setting) {
const updatedSetting = { const updatedSetting = {
...setting, ...setting,
method: selected.value, method: selected.value,
recipients: '', recipients: '',
cc: '',
bcc: '',
}; };
onUpdate(index, updatedSetting); onUpdate(index, updatedSetting);
@ -333,11 +376,49 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
} }
}; };
const onCcChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const { target } = event;
setCcValue(target.value);
if (onUpdate) {
const updatedSetting = {
...setting,
cc: target.value,
};
onUpdate(index, updatedSetting);
}
};
const onBccChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const { target } = event;
setBccValue(target.value);
if (onUpdate) {
const updatedSetting = {
...setting,
bcc: target.value,
};
onUpdate(index, updatedSetting);
}
};
// Set recipients // Set recipients
if (!!recipients && recipientValue !== recipients) { if (!!recipients && recipientValue !== recipients) {
setRecipientValue(recipients); setRecipientValue(recipients);
} }
if (!!cc && ccValue !== cc) {
setCcValue(cc);
}
if (!!bcc && bccValue !== bcc) {
setBccValue(bcc);
}
return ( return (
<StyledNotificationMethod> <StyledNotificationMethod>
<div className="inline-container"> <div className="inline-container">
@ -418,14 +499,16 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
<> <>
<div className="input-container"> <div className="input-container">
<textarea <textarea
name="recipients" name="To"
data-test="recipients" data-test="recipients"
value={recipientValue} value={recipientValue}
onChange={onRecipientsChange} onChange={onRecipientsChange}
/> />
</div> </div>
<div className="helper"> <div className="input-container">
{t('Recipients are separated by "," or ";"')} <div className="helper">
{t('Recipients are separated by "," or ";"')}
</div>
</div> </div>
</> </>
) : ( ) : (
@ -446,6 +529,75 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
</div> </div>
</StyledInputContainer> </StyledInputContainer>
</div> </div>
{method === NotificationMethodOption.Email && (
<StyledInputContainer>
{/* Render "CC" input field if ccVisible is true */}
{ccVisible && (
<>
<div className="control-label">
{TRANSLATIONS.EMAIL_CC_NAME}
</div>
<div className="input-container">
<textarea
name="CC"
data-test="cc"
value={ccValue}
onChange={onCcChange}
/>
</div>
<div className="input-container">
<div className="helper">
{t('Recipients are separated by "," or ";"')}
</div>
</div>
</>
)}
{/* Render "BCC" input field if bccVisible is true */}
{bccVisible && (
<>
<div className="control-label">
{TRANSLATIONS.EMAIL_BCC_NAME}
</div>
<div className="input-container">
<textarea
name="BCC"
data-test="bcc"
value={bccValue}
onChange={onBccChange}
/>
</div>
<div className="input-container">
<div className="helper">
{t('Recipients are separated by "," or ";"')}
</div>
</div>
</>
)}
{/* New buttons container */}
<div className="ghost-button">
<span
className="ghost-button"
role="button"
tabIndex={0}
onClick={() => setCcVisible(true)}
style={{ display: ccVisible ? 'none' : 'inline-flex' }}
>
<Icons.Email className="icon" />
{t('Add CC Recipients')}
</span>
<span
className="ghost-button"
role="button"
tabIndex={0}
onClick={() => setBccVisible(true)}
style={{ display: bccVisible ? 'none' : 'inline-flex' }}
>
<Icons.Email className="icon" />
{t('Add BCC Recipients')}
</span>
</div>
</StyledInputContainer>
)}
</> </>
) : null} ) : null}
</StyledNotificationMethod> </StyledNotificationMethod>

View File

@ -50,6 +50,8 @@ export enum NotificationMethodOption {
export type NotificationSetting = { export type NotificationSetting = {
method?: NotificationMethodOption; method?: NotificationMethodOption;
recipients: string; recipients: string;
cc?: string;
bcc?: string;
options: NotificationMethodOption[]; options: NotificationMethodOption[];
}; };
@ -63,6 +65,8 @@ export type SlackChannel = {
export type Recipient = { export type Recipient = {
recipient_config_json: { recipient_config_json: {
target: string; target: string;
ccTarget?: string;
bccTarget?: string;
}; };
type: NotificationMethodOption; type: NotificationMethodOption;
}; };

View File

@ -73,6 +73,8 @@ interface ReportProps {
show: boolean; show: boolean;
userId: number; userId: number;
userEmail: string; userEmail: string;
ccEmail: string;
bccEmail: string;
chart?: ChartState; chart?: ChartState;
chartName?: string; chartName?: string;
dashboardId?: number; dashboardId?: number;
@ -109,6 +111,8 @@ function ReportModal({
chart, chart,
userId, userId,
userEmail, userEmail,
ccEmail,
bccEmail,
creationMethod, creationMethod,
dashboardName, dashboardName,
chartName, chartName,
@ -184,7 +188,11 @@ function ReportModal({
owners: [userId], owners: [userId],
recipients: [ recipients: [
{ {
recipient_config_json: { target: userEmail }, recipient_config_json: {
target: userEmail,
ccTarget: ccEmail,
bccTarget: bccEmail,
},
type: 'Email', type: 'Email',
}, },
], ],

View File

@ -46,7 +46,14 @@ export interface ReportObject {
name: string; name: string;
owners: number[]; owners: number[];
recipients: [ recipients: [
{ recipient_config_json: { target: string }; type: ReportRecipientType }, {
recipient_config_json: {
target: string;
ccTarget: string;
bccTarget: string;
};
type: ReportRecipientType;
},
]; ];
report_format: string; report_format: string;
timezone: string; timezone: string;

View File

@ -193,11 +193,22 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
def _get_to(self) -> str: def _get_to(self) -> str:
return json.loads(self._recipient.recipient_config_json)["target"] return json.loads(self._recipient.recipient_config_json)["target"]
def _get_cc(self) -> str:
# To accomadate backward compatability
return json.loads(self._recipient.recipient_config_json).get("ccTarget", "")
def _get_bcc(self) -> str:
# To accomadate backward compatability
return json.loads(self._recipient.recipient_config_json).get("bccTarget", "")
@statsd_gauge("reports.email.send") @statsd_gauge("reports.email.send")
def send(self) -> None: def send(self) -> None:
subject = self._get_subject() subject = self._get_subject()
content = self._get_content() content = self._get_content()
to = self._get_to() to = self._get_to()
cc = self._get_cc()
bcc = self._get_bcc()
try: try:
send_email_smtp( send_email_smtp(
to, to,
@ -208,9 +219,10 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
data=content.data, data=content.data,
pdf=content.pdf, pdf=content.pdf,
images=content.images, images=content.images,
bcc="",
mime_subtype="related", mime_subtype="related",
dryrun=False, dryrun=False,
cc=cc,
bcc=bcc,
header_data=content.header_data, header_data=content.header_data,
) )
logger.info( logger.info(

View File

@ -123,6 +123,8 @@ class ValidatorConfigJSONSchema(Schema):
class ReportRecipientConfigJSONSchema(Schema): class ReportRecipientConfigJSONSchema(Schema):
# TODO if email check validity # TODO if email check validity
target = fields.String() target = fields.String()
ccTarget = fields.String()
bccTarget = fields.String()
class ReportRecipientSchema(Schema): class ReportRecipientSchema(Schema):

View File

@ -711,7 +711,7 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
recipients = smtp_mail_to recipients = smtp_mail_to
if cc: if cc:
smtp_mail_cc = get_email_address_list(cc) smtp_mail_cc = get_email_address_list(cc)
msg["CC"] = ", ".join(smtp_mail_cc) msg["Cc"] = ", ".join(smtp_mail_cc)
recipients = recipients + smtp_mail_cc recipients = recipients + smtp_mail_cc
smtp_mail_bcc = [] smtp_mail_bcc = []

View File

@ -108,6 +108,20 @@ def get_target_from_report_schedule(report_schedule: ReportSchedule) -> list[str
] ]
def get_cctarget_from_report_schedule(report_schedule: ReportSchedule) -> list[str]:
return [
json.loads(recipient.recipient_config_json).get("ccTarget", "")
for recipient in report_schedule.recipients
]
def get_bcctarget_from_report_schedule(report_schedule: ReportSchedule) -> list[str]:
return [
json.loads(recipient.recipient_config_json).get("bccTarget", "")
for recipient in report_schedule.recipients
]
def get_error_logs_query(report_schedule: ReportSchedule) -> BaseQuery: def get_error_logs_query(report_schedule: ReportSchedule) -> BaseQuery:
return ( return (
db.session.query(ReportExecutionLog) db.session.query(ReportExecutionLog)
@ -172,6 +186,20 @@ def create_report_email_chart():
cleanup_report_schedule(report_schedule) cleanup_report_schedule(report_schedule)
@pytest.fixture()
def create_report_email_chart_with_cc_and_bcc():
chart = db.session.query(Slice).first()
report_schedule = create_report_notification(
email_target="target@email.com",
ccTarget="cc@email.com",
bccTarget="bcc@email.com",
chart=chart,
)
yield report_schedule
cleanup_report_schedule(report_schedule)
@pytest.fixture() @pytest.fixture()
def create_report_email_chart_alpha_owner(get_user): def create_report_email_chart_alpha_owner(get_user):
owners = [get_user("alpha")] owners = [get_user("alpha")]
@ -617,6 +645,73 @@ def create_invalid_sql_alert_email_chart(request, app_context: AppContext):
cleanup_report_schedule(report_schedule) cleanup_report_schedule(report_schedule)
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices",
"create_report_email_chart_with_cc_and_bcc",
)
@patch("superset.reports.notifications.email.send_email_smtp")
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
def test_email_chart_report_schedule_with_cc_bcc(
screenshot_mock,
email_mock,
create_report_email_chart_with_cc_and_bcc,
):
"""
ExecuteReport Command: Test chart email report schedule with screenshot and email cc, bcc options
"""
# setup screenshot mock
screenshot_mock.return_value = SCREENSHOT_FILE
with freeze_time("2020-01-01T00:00:00Z"):
AsyncExecuteReportScheduleCommand(
TEST_ID, create_report_email_chart_with_cc_and_bcc.id, datetime.utcnow()
).run()
notification_targets = get_target_from_report_schedule(
create_report_email_chart_with_cc_and_bcc
)
notification_cctargets = get_cctarget_from_report_schedule(
create_report_email_chart_with_cc_and_bcc
)
notification_bcctargets = get_bcctarget_from_report_schedule(
create_report_email_chart_with_cc_and_bcc
)
# assert that the link sent is correct
assert (
'<a href="http://0.0.0.0:8080/explore/?form_data=%7B%22slice_id%22:+'
f"{create_report_email_chart_with_cc_and_bcc.chart.id}"
'%7D&force=false">Explore in Superset</a>' in email_mock.call_args[0][2]
)
# Assert the email smtp address
if notification_targets:
assert email_mock.call_args[0][0] == notification_targets[0]
# Assert the cc recipients if provided
if notification_cctargets:
expected_cc_targets = [target.strip() for target in notification_cctargets]
assert (
email_mock.call_args[1].get("cc", "").split(",") == expected_cc_targets
)
if notification_bcctargets:
expected_bcc_targets = [
target.strip() for target in notification_bcctargets
]
assert (
email_mock.call_args[1].get("bcc", "").split(",")
== expected_bcc_targets
)
# Assert the email inline screenshot
smtp_images = email_mock.call_args[1]["images"]
assert smtp_images[list(smtp_images.keys())[0]] == SCREENSHOT_FILE
# Assert logs are correct
assert_log(ReportState.SUCCESS)
@pytest.mark.usefixtures( @pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "create_report_email_chart" "load_birth_names_dashboard_with_slices", "create_report_email_chart"
) )

View File

@ -116,6 +116,8 @@ def create_report_notification(
extra: Optional[dict[str, Any]] = None, extra: Optional[dict[str, Any]] = None,
force_screenshot: bool = False, force_screenshot: bool = False,
owners: Optional[list[User]] = None, owners: Optional[list[User]] = None,
ccTarget: Optional[str] = None,
bccTarget: Optional[str] = None,
) -> ReportSchedule: ) -> ReportSchedule:
if not owners: if not owners:
owners = [ owners = [
@ -138,7 +140,9 @@ def create_report_notification(
else: else:
recipient = ReportRecipients( recipient = ReportRecipients(
type=ReportRecipientType.EMAIL, type=ReportRecipientType.EMAIL,
recipient_config_json=json.dumps({"target": email_target}), recipient_config_json=json.dumps(
{"target": email_target, "ccTarget": ccTarget, "bccTarget": bccTarget}
),
) )
if name is None: if name is None: