import { CallExpressionShape, Plugin, isGUID, Shape, type ObjectShape } from '@servicenow/sdk-build-core' import { NowIdShape } from './now-id-plugin' import { showGuidFieldDiagnostic } from './utils' import type { EmailNotification } from '@servicenow/sdk-core/runtime/notification' // Use index access types instead of exported utility types type TriggerConditions = NonNullable['triggerConditions']> type RecipientDetails = NonNullable['recipientDetails']> type EmailContent = NonNullable['emailContent']> type Digest = NonNullable['digest']> const DEFAULT_EMAIL_CATEGORY = 'c97d83137f4432005f58108c3ffa917a' const DEFAULT_DIGEST_SEPARATOR_HTML = '

 

\n
\n

 

' const DEFAULT_DIGEST_SEPARATOR_TEXT = '--------------------------------------------------------------------------------\n' // Helper to conditionally add property to object, Skips undefined and empty strings, but includes false/0/null const addProperty = (obj: Record, key: string, value: unknown) => { if (value === undefined) { return } if (typeof value === 'string' && value === '') { return } // Skip empty strings obj[key] = value } // Utility function to get a nested object const getNestedObjectProp = (arg: ObjectShape, key: string): ObjectShape | null => { return arg.get(key).ifObject()?.asObject() || null // Returns the object or null } // Utility function to join array elements into a comma-separated string const joinArrayToString = (array: Shape | undefined): string => { return array?.ifArray() ? array.asArray().getValue().join(',') : '' // Join array elements with a comma } // Helper to convert comma-separated string to array (used in toShape) const commaStringToArray = (value: string | undefined): string[] | undefined => { if (!value || value.trim() === '') { return undefined } return value .split(',') .map((v) => v.trim()) .filter((v) => v !== '') } // Helper to validate email format (basic validation) const isValidEmail = (email: string): boolean => { // Simple email regex: allows most valid emails while catching obvious errors const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ return emailRegex.test(email) } export const EmailNotificationPlugin = Plugin.create({ name: 'EmailNotificationPlugin', records: { sysevent_email_action: { toShape(record) { const generationType = record.get('generation_type')?.getValue() ?? 'engine' return { success: true, value: new CallExpressionShape({ source: record, callee: 'EmailNotification', args: [ record.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(record)), table: $.from('collection'), name: $, description: $.def(''), category: $, notificationType: $.from('type').def('email'), active: $.toBoolean().def(true), mandatory: $.toBoolean().def(false), enableDynamicTranslation: $.from('enable_dynamic_translation').toBoolean().def(false), // Build triggerConditions using $.from().map() triggerConditions: $.from( 'generation_type', 'action_insert', 'action_update', 'event_name', 'weight', 'order', 'condition', 'advanced_condition', 'item_table', 'affected_field_on_event', 'item' ).map( ( genType, actionInsert, actionUpdate, eventName, weight, order, condition, advancedCondition, itemTable, affectedField, item ) => { const triggerConditions: TriggerConditions = { generationType: (genType?.getValue() ?? 'engine') as NonNullable< TriggerConditions['generationType'] >, } const genTypeValue = genType?.getValue() ?? 'engine' if (genTypeValue === 'engine') { addProperty( triggerConditions, 'onRecordInsert', actionInsert?.toBoolean().getValue() ) addProperty( triggerConditions, 'onRecordUpdate', actionUpdate?.toBoolean().getValue() ) } else if (genTypeValue === 'event') { addProperty(triggerConditions, 'eventName', eventName?.getValue()) } const weightValue = weight?.getValue() if (weightValue !== undefined && weightValue !== '' && weightValue !== null) { const weightNum = Number(weightValue) if (!isNaN(weightNum) && weightNum !== 0) { triggerConditions.weight = weightNum } } const orderValue = order?.getValue() if (orderValue !== undefined && orderValue !== '' && orderValue !== null) { const orderNum = Number(orderValue) if (!isNaN(orderNum) && orderNum !== 100) { triggerConditions.order = orderNum } } addProperty(triggerConditions, 'condition', condition?.getValue()) addProperty( triggerConditions, 'advancedCondition', advancedCondition?.getValue() ) addProperty(triggerConditions, 'itemTable', itemTable?.getValue()) addProperty( triggerConditions, 'affectedFieldOnEvent', affectedField?.getValue() ) addProperty(triggerConditions, 'item', item?.getValue()) return triggerConditions } ), // Build recipientDetails using $.from().map() recipientDetails: $.from( 'recipient_users', 'recipient_fields', 'recipient_groups', 'exclude_delegates', 'subscribable', 'send_self', 'event_parm_1', 'event_parm_2' ).map( ( users, fields, groups, excludeDelegates, subscribable, sendSelf, eventParm1, eventParm2 ) => { const recipientDetails: RecipientDetails = {} addProperty( recipientDetails, 'recipientUsers', commaStringToArray(users?.getValue() as string) ) addProperty( recipientDetails, 'recipientFields', commaStringToArray(fields?.getValue() as string) ) addProperty( recipientDetails, 'recipientGroups', commaStringToArray(groups?.getValue() as string) ) addProperty( recipientDetails, 'excludeDelegates', excludeDelegates?.toBoolean().getValue() ) addProperty( recipientDetails, 'isSubscribableByAllUsers', subscribable?.toBoolean().getValue() ) addProperty(recipientDetails, 'sendToCreator', sendSelf?.toBoolean().getValue()) if (generationType === 'event') { addProperty( recipientDetails, 'eventParm1WithRecipient', eventParm1?.toBoolean().getValue() ) addProperty( recipientDetails, 'eventParm2WithRecipient', eventParm2?.toBoolean().getValue() ) } return Object.keys(recipientDetails).length > 0 ? recipientDetails : undefined } ), // Build emailContent using $.from().map() emailContent: $.from( 'content_type', 'message_html', 'message_text', 'template', 'style', 'subject', 'message', 'sms_alternate', 'importance', 'from', 'reply_to', 'message_list', 'include_attachments', 'omit_watermark', 'push_message_only', 'force_delivery' ).map( ( contentTypeField, messageHtml, messageText, template, style, subject, message, smsAlternate, importance, from, replyTo, messageList, includeAttachments, omitWatermark, pushMessageOnly, forceDelivery ) => { const emailContent: EmailContent = {} const contentTypeValue = contentTypeField?.getValue() || 'text/html' if (contentTypeValue !== 'text/html') { emailContent.contentType = contentTypeValue as NonNullable< EmailContent['contentType'] > } if ( contentTypeValue === 'text/html' || contentTypeValue === 'multipart/mixed' ) { addProperty(emailContent, 'messageHtml', messageHtml?.getValue()) } if ( contentTypeValue === 'text/plain' || contentTypeValue === 'multipart/mixed' ) { addProperty(emailContent, 'messageText', messageText?.getValue()) } addProperty(emailContent, 'template', template?.getValue()) addProperty(emailContent, 'style', style?.getValue()) addProperty(emailContent, 'subject', subject?.getValue()) addProperty(emailContent, 'message', message?.getValue()) addProperty(emailContent, 'smsAlternate', smsAlternate?.getValue()) addProperty(emailContent, 'importance', importance?.getValue()) addProperty(emailContent, 'from', from?.getValue()) addProperty(emailContent, 'replyTo', replyTo?.getValue()) addProperty( emailContent, 'pushMessageList', commaStringToArray(messageList?.getValue() as string) ) addProperty( emailContent, 'includeAttachments', includeAttachments?.toBoolean().getValue() ) addProperty( emailContent, 'omitWatermark', omitWatermark?.toBoolean().getValue() ) addProperty( emailContent, 'pushMessageOnly', pushMessageOnly?.toBoolean().getValue() ) addProperty( emailContent, 'forceDelivery', forceDelivery?.toBoolean().getValue() ) return Object.keys(emailContent).length > 0 ? emailContent : undefined } ), // Build digest using $.from().map() digest: $.from( 'digestable', 'default_digest', 'default_interval', 'digest_type', 'digest_template', 'digest_subject', 'digest_html', 'digest_text', 'digest_separator_html', 'digest_separator_text', 'digest_from', 'digest_reply_to' ).map( ( digestable, defaultDigest, defaultInterval, digestType, digestTemplate, digestSubject, digestHtml, digestText, separatorHtml, separatorText, digestFrom, digestReplyTo ) => { const allowDigest = digestable?.ifDefined()?.toBoolean().getValue() if (!allowDigest) { return undefined } const digest: Digest = { allow: true } addProperty( digest, 'default', defaultDigest?.ifDefined()?.toBoolean().getValue() ) addProperty(digest, 'defaultInterval', defaultInterval?.getValue()) const digestTypeValue = digestType?.getValue() if (digestTypeValue && digestTypeValue !== 'single') { digest.type = digestTypeValue as NonNullable } addProperty(digest, 'template', digestTemplate?.getValue()) addProperty(digest, 'subject', digestSubject?.getValue()) addProperty(digest, 'html', digestHtml?.getValue()) addProperty(digest, 'text', digestText?.getValue()) const separatorHtmlValue = separatorHtml?.getValue() as string if ( separatorHtmlValue && separatorHtmlValue !== DEFAULT_DIGEST_SEPARATOR_HTML ) { digest.separatorHtml = separatorHtmlValue } const separatorTextValue = separatorText?.getValue() as string if ( separatorTextValue && separatorTextValue !== DEFAULT_DIGEST_SEPARATOR_TEXT ) { digest.separatorText = separatorTextValue } addProperty(digest, 'from', digestFrom?.getValue()) addProperty(digest, 'replyTo', digestReplyTo?.getValue()) return digest } ), })), ], }), } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, diagnostics }) { if (callExpression.getCallee() !== 'EmailNotification') { return { success: false } } const arg = callExpression.getArgument(0).asObject() // Extract nested objects const triggerConditions = getNestedObjectProp(arg, 'triggerConditions') const recipientDetails = getNestedObjectProp(arg, 'recipientDetails') const emailContent = getNestedObjectProp(arg, 'emailContent') const digest = getNestedObjectProp(arg, 'digest') // Handle recipientUsers - array of string (GUID/email) or Record<'sys_user'> let recipientUsers: string | undefined if (recipientDetails) { const recipientUsersArg = recipientDetails.get('recipientUsers') if (recipientUsersArg?.isArray()) { const elements = recipientUsersArg.getElements() const userList: string[] = [] elements.forEach((element, index) => { if (element.isRecord()) { // Extract ID from Record userList.push(element.getId().getValue()) } else if (element.isString()) { const str = element.getValue() // Accept either GUID or email address if (isGUID(str) || isValidEmail(str)) { userList.push(str) } else { diagnostics.error( element, `'recipientDetails.recipientUsers[${index}]' must be a valid GUID or email address or a Record<'sys_user'>. Received: '${str}'` ) } } }) if (userList.length > 0) { recipientUsers = userList.join(',') } } } const recipientFields = joinArrayToString(recipientDetails?.get('recipientFields')) // Handle recipientGroups - array of string (GUID) or Record<'sys_user_group'> let recipientGroups: string | undefined if (recipientDetails) { const recipientGroupsArg = recipientDetails.get('recipientGroups') if (recipientGroupsArg?.isArray()) { const elements = recipientGroupsArg.getElements() const guidList: string[] = [] elements.forEach((element, index) => { if (element.isRecord()) { // Extract ID from Record guidList.push(element.getId().getValue()) } else if (element.isString()) { const str = element.getValue() // Only accept GUID (no email for groups) if (isGUID(str)) { guidList.push(str) } else { showGuidFieldDiagnostic( element, `recipientDetails.recipientGroups[${index}]`, 'sys_user_group', diagnostics ) } } }) if (guidList.length > 0) { recipientGroups = guidList.join(',') } } } // Handle pushMessageList - array of string (GUID) or Record<'sys_push_notif_msg'> let pushMessageList: string | undefined if (emailContent) { const pushMessageListArg = emailContent.get('pushMessageList') if (pushMessageListArg?.isArray()) { const elements = pushMessageListArg.getElements() const guidList: string[] = [] elements.forEach((element, index) => { if (element.isRecord()) { // Extract ID from Record guidList.push(element.getId().getValue()) } else if (element.isString()) { const str = element.getValue() // Add GUID string if (isGUID(str)) { guidList.push(str) } else { showGuidFieldDiagnostic( element, `emailContent.pushMessageList[${index}]`, 'sys_push_notif_msg', diagnostics ) } } }) // Convert to comma-separated string if (guidList.length > 0) { pushMessageList = guidList.join(',') } } } const digestSeparatorHtml = digest?.get('separatorHtml')?.isDefined() ? digest.get('separatorHtml').asString().withContentType('cdata') : Shape.from(callExpression, DEFAULT_DIGEST_SEPARATOR_HTML).asString().withContentType('cdata') const digestSeparatorText = digest?.get('separatorText')?.isDefined() ? digest.get('separatorText').asString().withContentType('cdata') : Shape.from(callExpression, DEFAULT_DIGEST_SEPARATOR_TEXT).asString().withContentType('cdata') // Handle template field - can be string (GUID) or Record<'sysevent_email_template'> let templateValue: string | undefined if (emailContent) { const templateArg = emailContent.get('template') if (templateArg) { if (templateArg.isRecord()) { // If it's a Record, extract the ID templateValue = templateArg.getId().getValue() } else if (templateArg.isString()) { const templateStr = templateArg.getValue() // If it's a string GUID, use it if (isGUID(templateStr)) { templateValue = templateStr } else { showGuidFieldDiagnostic( templateArg, 'emailContent.template', 'sysevent_email_template', diagnostics ) } } } } // Handle style field - can be string (GUID) or Record<'sysevent_email_style'> let styleValue: string | undefined if (emailContent) { const styleArg = emailContent.get('style') if (styleArg) { if (styleArg.isRecord()) { // If it's a Record, extract the ID styleValue = styleArg.getId().getValue() } else if (styleArg.isString()) { const styleStr = styleArg.getValue() // If it's a string GUID, use it if (isGUID(styleStr)) { styleValue = styleStr } else { showGuidFieldDiagnostic( styleArg, 'emailContent.style', 'sysevent_email_style', diagnostics ) } } } } // Handle defaultInterval field - can be string (GUID) or Record<'sys_email_digest_interval'> let defaultIntervalValue: string | undefined if (digest) { const defaultIntervalArg = digest.get('defaultInterval') if (defaultIntervalArg) { if (defaultIntervalArg.isRecord()) { // If it's a Record, extract the ID defaultIntervalValue = defaultIntervalArg.getId().getValue() } else if (defaultIntervalArg.isString()) { const defaultIntervalStr = defaultIntervalArg.getValue() // If it's a string GUID, use it if (isGUID(defaultIntervalStr)) { defaultIntervalValue = defaultIntervalStr } else { showGuidFieldDiagnostic( defaultIntervalArg, 'digest.defaultInterval', 'sys_email_digest_interval', diagnostics ) } } } } // Handle digest template field - can be string (GUID) or Record<'sysevent_email_template'> let digestTemplateValue: string | undefined if (digest) { const digestTemplateArg = digest.get('template') if (digestTemplateArg) { if (digestTemplateArg.isRecord()) { // If it's a Record, extract the ID digestTemplateValue = digestTemplateArg.getId().getValue() } else if (digestTemplateArg.isString()) { const digestTemplateStr = digestTemplateArg.getValue() // If it's a string GUID, use it if (isGUID(digestTemplateStr)) { digestTemplateValue = digestTemplateStr } else { showGuidFieldDiagnostic( digestTemplateArg, 'digest.template', 'sysevent_email_template', diagnostics ) } } } } // Business rule validations const notificationType = arg.get('notificationType')?.getValue() ?? 'email' const generationType = triggerConditions?.get('generationType')?.getValue() ?? 'engine' // Validate: engine type requires at least one of onRecordInsert or onRecordUpdate if (generationType === 'engine' && triggerConditions) { const onRecordInsert = triggerConditions.get('onRecordInsert')?.getValue() const onRecordUpdate = triggerConditions.get('onRecordUpdate')?.getValue() if (!onRecordInsert && !onRecordUpdate) { diagnostics.error( triggerConditions, "When triggerConditions.generationType is 'engine', at least one of 'onRecordInsert' or 'onRecordUpdate' must be true." ) } } // Validate: event type requires eventName if (generationType === 'event' && triggerConditions) { const eventName = triggerConditions.get('eventName')?.getValue() if (!eventName || (typeof eventName === 'string' && eventName.trim() === '')) { diagnostics.error( triggerConditions, "When triggerConditions.generationType is 'event', 'eventName' is required." ) } } // Validate: vcalendar does not support digest if (notificationType === 'vcalendar' && digest) { const allowDigest = digest.get('allow')?.getValue() if (allowDigest === true) { diagnostics.warn( digest, "Digest functionality is not supported for notificationType 'vcalendar'. The digest configuration will be ignored." ) } } // Validate: eventParm fields only apply to event generation type if (generationType !== 'event' && recipientDetails) { const eventParm1 = recipientDetails.get('eventParm1WithRecipient')?.getValue() const eventParm2 = recipientDetails.get('eventParm2WithRecipient')?.getValue() if (eventParm1 === true || eventParm2 === true) { diagnostics.warn( recipientDetails, `'eventParm1WithRecipient' and 'eventParm2WithRecipient' only apply when triggerConditions.generationType is 'event'. Current type is '${generationType}'.` ) } } // Validate: content type and message field alignment if (emailContent && !emailContent.get('template')?.getValue()) { const contentType = emailContent.get('contentType')?.getValue() ?? 'text/html' const hasMessageHtml = emailContent.get('messageHtml') !== undefined const hasMessageText = emailContent.get('messageText') !== undefined const messageHtmlValue = emailContent.get('messageHtml')?.getValue() const messageTextValue = emailContent.get('messageText')?.getValue() if (contentType === 'text/html') { // Warn if messageHtml is missing if (!hasMessageHtml || !messageHtmlValue) { diagnostics.warn( emailContent, `Content type is 'text/html' but 'messageHtml' is not provided. Please provide 'messageHtml' for HTML content.` ) } // Warn if messageText is provided (it will be ignored) if (hasMessageText && messageTextValue) { diagnostics.warn( emailContent, `Content type is 'text/html': 'messageText' will be ignored. Only 'messageHtml' will be used.` ) } } else if (contentType === 'text/plain') { // Warn if messageText is missing if (!hasMessageText || !messageTextValue) { diagnostics.warn( emailContent, `Content type is 'text/plain' but 'messageText' is not provided. Please provide 'messageText' for plain text content.` ) } // Warn if messageHtml is provided (it will be ignored) if (hasMessageHtml && messageHtmlValue) { diagnostics.warn( emailContent, `Content type is 'text/plain': 'messageHtml' will be ignored. Only 'messageText' will be used.` ) } } else if (contentType === 'multipart/mixed') { // Warn if both are missing const hasAnyContent = (hasMessageHtml && messageHtmlValue) || (hasMessageText && messageTextValue) if (!hasAnyContent) { diagnostics.warn( emailContent, `Content type is 'multipart/mixed' but neither 'messageHtml' nor 'messageText' is provided. Please provide at least one or both.` ) } } } // Handle category field - can be string (GUID) or Record<'sys_notification_category'> const categoryArg = arg.get('category') let categoryValue = DEFAULT_EMAIL_CATEGORY if (categoryArg) { if (categoryArg.isRecord()) { // If it's a Record, extract the ID categoryValue = categoryArg.getId().getValue() } else if (categoryArg.isString()) { const categoryStr = categoryArg.getValue() // If it's a string GUID, use it if (isGUID(categoryStr)) { categoryValue = categoryStr } else { showGuidFieldDiagnostic(categoryArg, 'category', 'sys_notification_category', diagnostics) } } } const emailAction = await factory.createRecord({ explicitId: arg.get('$id'), source: callExpression, table: 'sysevent_email_action', properties: arg.transform(({ $ }) => ({ // Base properties name: $, description: $.def(''), collection: $.from('table'), category: $.val(categoryValue), type: $.from('notificationType').def('email'), active: $.def(true), mandatory: $.def(false), enable_dynamic_translation: $.from('enableDynamicTranslation').def(false), // Trigger conditions generation_type: $.val(triggerConditions?.get('generationType')).def('engine'), action_insert: $.val(triggerConditions?.get('onRecordInsert')).def(false), action_update: $.val(triggerConditions?.get('onRecordUpdate')).def(false), event_name: $.val(triggerConditions?.get('eventName')).def(''), condition: $.val(triggerConditions?.get('condition')).def(''), advanced_condition: $.val(triggerConditions?.get('advancedCondition')).def(''), item_table: $.val(triggerConditions?.get('itemTable')).def(''), affected_field_on_event: $.val(triggerConditions?.get('affectedFieldOnEvent')).def(''), item: $.val(triggerConditions?.get('item')).def('event.parm1'), order: $.val(triggerConditions?.get('order')).toNumber().def(100), weight: $.val(triggerConditions?.get('weight')).toNumber().def(0), // Recipient details - use pre-extracted comma-separated strings recipient_users: $.val(recipientUsers).def(''), recipient_fields: $.val(recipientFields).def(''), recipient_groups: $.val(recipientGroups).def(''), exclude_delegates: $.val(recipientDetails?.get('excludeDelegates')).def(false), subscribable: $.val(recipientDetails?.get('isSubscribableByAllUsers')).def(false), send_self: $.val(recipientDetails?.get('sendToCreator')).def(false), event_parm_1: $.val(recipientDetails?.get('eventParm1WithRecipient')).def(false), event_parm_2: $.val(recipientDetails?.get('eventParm2WithRecipient')).def(false), // Email content content_type: $.val(emailContent?.get('contentType')).def('text/html'), template: $.val(templateValue).def(''), style: $.val(styleValue).def(''), subject: $.val(emailContent?.get('subject')).def(''), message: $.val(emailContent?.get('message')).def('').toCdata(), message_html: $.val(emailContent?.get('messageHtml')).def('').toCdata(), message_text: $.val(emailContent?.get('messageText')).def('').toCdata(), sms_alternate: $.val(emailContent?.get('smsAlternate')).def('').toCdata(), importance: $.val(emailContent?.get('importance')).def(''), include_attachments: $.val(emailContent?.get('includeAttachments')).def(false), omit_watermark: $.val(emailContent?.get('omitWatermark')).def(false), from: $.val(emailContent?.get('from')).def(''), reply_to: $.val(emailContent?.get('replyTo')).def(''), push_message_only: $.val(emailContent?.get('pushMessageOnly')).def(false), message_list: $.val(pushMessageList).def(''), force_delivery: $.val(emailContent?.get('forceDelivery')).def(false), // Digest digestable: $.val(digest?.get('allow')).def(false), default_digest: $.val(digest?.get('default')).def(false), default_interval: $.val(defaultIntervalValue).def(''), digest_type: $.val(digest?.get('type')).def('single'), digest_template: $.val(digestTemplateValue).def(''), digest_subject: $.val(digest?.get('subject')).def(''), digest_html: $.val(digest?.get('html')).def('').toCdata(), digest_text: $.val(digest?.get('text')).toCdata().def(''), digest_separator_html: $.val(digestSeparatorHtml).toCdata(), digest_separator_text: $.val(digestSeparatorText).toCdata(), digest_from: $.val(digest?.get('from')).def(''), digest_reply_to: $.val(digest?.get('replyTo')).def(''), sys_version: $.val('2'), })), }) return { success: true, value: emailAction, } }, }, ], })