What It Is
Email Scripts are server-side JavaScript snippets embedded within Email Templates using the ${mailto:} syntax that execute during notification generation to produce dynamic email content. They solve the fundamental problem of creating meaningful, contextual notifications that go beyond static templates by allowing real-time data retrieval, complex business logic, and personalized messaging based on the triggering record, recipient, and current system state. Without Email Scripts, you're limited to basic variable substitution using ${field_name} syntax, which becomes inadequate the moment you need conditional content, calculated values, or data from related records.
Architecturally, Email Scripts execute purely on the server-side within the notification engine's context, never touching the browser or client-side JavaScript APIs. This execution happens in the application server's JavaScript engine (Rhino/Nashorn depending on your version) with access to server-side APIs like GlideRecord, gs utilities, and Script Includes, but zero access to client-side objects like g_form or GlideUser. The execution environment provides a current object representing the triggering record and an email object containing recipient information, both crucial for meaningful script logic.
The underlying mechanism involves ServiceNow's notification engine parsing Email Templates during the notification generation process, identifying ${mailto:} blocks, extracting the JavaScript code, and executing it in a sandboxed server-side context. The script's return value or output (via template.print()) replaces the entire ${mailto:} block in the final email content. This happens after Business Rules and before the email hits the outbound queue, giving you the last opportunity to influence email content based on the record's final state. Critical point: any database changes made during this execution won't trigger additional notifications, preventing infinite loops but also meaning your script sees a snapshot of data at notification trigger time.
Without Email Scripts, you cannot create notifications that adapt to business context, calculate derived values, or pull related record data beyond direct reference fields. You're stuck with static templates that treat every incident closure the same way, every approval request identically, and every assignment notification as generic. The moment you need to show different content based on priority, category, assignment group, or any business logic more complex than "if field X equals Y," you need Email Scripts. They're the difference between spam-like system notifications and contextual communications that users actually find valuable.
Email Scripts are primarily a developer tool, though system administrators with JavaScript experience often write simple ones for basic conditional logic. Platform architects design Email Script patterns for enterprise implementations, creating reusable Script Includes that Email Scripts can call to maintain consistency across notification templates. The typical use cases span incident escalation notifications with SLA calculations, approval workflows with dynamic approver lists, change management communications with affected CI details, and service catalog fulfillment updates with custom delivery information.
Email Scripts relate closely to Business Rules and Script Actions, sharing the same server-side execution environment and access patterns, but executing later in the notification pipeline with access to the final record state. They complement Mail Scripts (the older mail_script table) by providing inline scripting versus separate script records, and they work alongside Notification Filters to determine not just who gets notifications, but what content they receive. Understanding Email Scripts means grasping their position in the notification lifecycle: after Business Rules determine what changed, after Notification Filters determine who should be notified, but before the actual email composition and delivery.
How It Works Under the Hood
The notification engine processes Email Scripts through a multi-stage pipeline that begins when a notification condition is met and ends with rendered email content. ServiceNow's email processor scans the Email Template content for ${mailto:} blocks using regular expression matching, extracts the JavaScript code within each block, and queues it for execution in a server-side JavaScript context. This context includes pre-populated objects like current (the triggering record), email (recipient information), previous (record state before changes), and template (output methods), but crucially excludes session-dependent objects since notifications often run in background threads without user context.
The execution happens in the same JavaScript engine used by Business Rules and Script Includes, meaning you have access to the full server-side API surface including GlideRecord, GlideSystem, custom Script Includes, and web service calls. However, the execution context lacks user session information, so methods that depend on gs.getUserID() return the notification sender (often system) rather than the triggering user. The script output gets captured through either the final statement's return value or explicit calls to template.print(), then substituted back into the email template where the ${mailto:} block originally appeared.
A critical aspect most developers miss is that Email Scripts execute once per recipient, not once per notification trigger. If your notification goes to five people, your script runs five times with the same current record but different email objects representing each recipient. This enables personalized content per recipient but can cause performance issues if your script makes expensive database queries or web service calls. The execution also happens synchronously within the notification processing thread, so slow Email Scripts directly impact notification delivery performance and can cause timeouts in high-volume environments.
The Execution Lifecycle
- Business Rule or workflow triggers notification based on record changes or conditions
- Notification engine evaluates notification conditions and determines recipient list
- Email Template content gets loaded and parsed for variable substitution blocks
- Simple field variables (
${number},${priority}) get replaced with record values - Email Script blocks (
${mailto:...}) get identified and queued for execution - For each recipient, server-side JavaScript context gets initialized with
current,email,previous, andtemplateobjects - Email Script executes synchronously, with output captured via return value or
template.print()calls - Script output replaces the
${mailto:}block in the final email content for that recipient - Completed email gets queued for delivery with personalized content per recipient
Enjoying this? Get one deep-dive per week.
Join 1,000+ ServiceNow pros — scripts, GlideRecord patterns, Flow Designer techniques, and career moves. Free.
The Core Pattern
<!-- Email Template content with embedded Email Script -->
<h2>Incident ${number} - ${priority} Priority</h2>
<p>Assigned to: ${assigned_to.name}</p>
${mailto:
// Email Script block - runs server-side for each recipient
// Access current record via 'current' object
// Access recipient info via 'email' object
// Output content via template.print() or return statement
var message = '';
// Personalize based on recipient role
if (email.recipients.indexOf(current.assigned_to.email.toString()) > -1) {
// This email is going to the assignee
template.print('<p><strong>This incident has been assigned to you.</strong></p>');
// Add priority-specific instructions
if (current.priority == '1') {
template.print('<p style="color: red;">HIGH PRIORITY: Please respond within 1 hour.</p>');
}
} else {
// This email is going to watchers/managers
template.print('<p>This incident has been assigned to ' + current.assigned_to.name + '</p>');
}
// Add related problem information if exists
if (!gs.nil(current.problem_id)) {
var problem = new GlideRecord('problem');
if (problem.get(current.problem_id)) {
template.print('<p><em>Related Problem: ' + problem.number + ' - ' + problem.short_description + '</em></p>');
}
}
}// Script Include for reusable notification logic
var NotificationHelper = Class.create();
NotificationHelper.prototype = {
initialize: function() {
},
// Get personalized greeting based on time and recipient preferences
getPersonalizedGreeting: function(recipientSysId) {
var user = new GlideRecord('sys_user');
if (!user.get(recipientSysId)) {
return 'Hello,';
}
var hour = new GlideDateTime().getHour();
var timeOfDay = (hour < 12) ? 'morning' : (hour < 17) ? 'afternoon' : 'evening';
// Respect user's display preference
var greeting = 'Good ' + timeOfDay;
if (!gs.nil(user.first_name)) {
greeting += ', ' + user.first_name.toString();
}
return greeting + ',';
},
// Get escalation path for current record
getEscalationInfo: function(record, recipientEmail) {
// Logic to determine next escalation level
// Returns formatted string with escalation timeline
var info = 'Next escalation in ' + this._calculateEscalationTime(record);
return info;
},
type: 'NotificationHelper'
};Always use template.print() for complex output rather than building massive return strings. It's more readable and performs better for large content blocks.
Email Scripts execute once per recipient. Never put expensive operations like web service calls directly in the script without caching or recipient-specific logic.
Real-World Scenarios
SLA Breach Warning with Calculated Time Remaining
Critical incidents need dynamic SLA notifications that show actual time remaining and escalation consequences. Static templates can't calculate real-time SLA status or adjust messaging based on how close the breach is.
${mailto:
// Calculate actual SLA time remaining for this specific incident
var slaGR = new GlideRecord('task_sla');
slaGR.addQuery('task', current.sys_id);
slaGR.addQuery('stage', 'in_progress');
slaGR.query();
while (slaGR.next()) {
var slaName = slaGR.sla.definition.name.toString();
var plannedEnd = new GlideDateTime(slaGR.planned_end_time);
var now = new GlideDateTime();
// Calculate time remaining in hours
var diffMs = plannedEnd.getNumericValue() - now.getNumericValue();
var hoursRemaining = Math.floor(diffMs / (1000 * 60 * 60));
template.print('<div style="border-left: 3px solid #ff6b35; padding: 10px; margin: 10px 0;">');;
template.print('<h3>SLA Alert: ' + slaName + '</h3>');
if (hoursRemaining > 0) {
template.print('<p><strong>' + hoursRemaining + ' hours remaining</strong> before breach.</p>');
} else {
template.print('<p style="color: red;"><strong>SLA BREACHED</strong> ' + Math.abs(hoursRemaining) + ' hours ago!</p>');
}
// Add escalation warning based on time criticality
if (hoursRemaining <= 2 && hoursRemaining > 0) {
template.print('<p>⚠️ <em>Auto-escalation will occur if not resolved within 2 hours.</em></p>');
}
template.print('</div>');
}
}Watch for timezone issues when calculating SLA times - GlideDateTime objects use system timezone, but your SLA definitions might use user or location-specific timezones. Also consider that this script runs for each recipient, so expensive SLA calculations should be cached in a Script Include to avoid repeated database queries for the same incident.
Change Advisory Board Digest with Risk Assessment
Weekly CAB meetings require comprehensive change summaries with risk analysis and conflict detection. Manual compilation is error-prone and time-consuming, requiring dynamic content based on change attributes, affected CIs, and scheduling conflicts.
${mailto:
// Generate weekly CAB digest with risk analysis
var startOfWeek = new GlideDateTime();
startOfWeek.setDisplayValue(gs.beginningOfWeek());
var endOfWeek = new GlideDateTime(startOfWeek);
endOfWeek.addWeeksLocalTime(1);
// Query normal changes for this week
var changes = new GlideRecord('change_request');
changes.addQuery('type', 'normal');
changes.addQuery('start_date', '>=', startOfWeek.getDisplayValue());
changes.addQuery('start_date', '<', endOfWeek.getDisplayValue());
changes.addQuery('state', 'NOT IN', 'closed,cancelled');
changes.orderBy('risk');
changes.orderByDesc('start_date');
changes.query();
var highRiskCount = 0;
var totalCount = 0;
template.print('<h2>CAB Review: Week of ' + startOfWeek.getDisplayValue() + '</h2>');
template.print('<table border="1" style="border-collapse: collapse; width: 100%;">');;
template.print('<tr style="background-color: #f0f0f0;"><th>Change</th><th>Risk</th><th>Start Date</th><th>Conflicts</th></tr>');
while (changes.next()) {
totalCount++;
if (changes.risk >= 3) highRiskCount++;
// Check for scheduling conflicts with other changes
var conflicts = new GlideRecord('change_request');
conflicts.addQuery('sys_id', '!=', changes.sys_id);
conflicts.addQuery('start_date', '>=', changes.start_date);
conflicts.addQuery('end_date', '<=', changes.end_date);
conflicts.query();
var conflictText = conflicts.getRowCount() > 0 ? conflicts.getRowCount() + ' conflicts' : 'None';
template.print('<tr>');
template.print('<td>' + changes.number + ': ' + changes.short_description + '</td>');
template.print('<td>' + changes.risk.getDisplayValue() + '</td>');
template.print('<td>' + changes.start_date.getDisplayValue() + '</td>');
template.print('<td>' + conflictText + '</td>');
template.print('</tr>');
}
template.print('</table>');
template.print('<p><strong>Summary:</strong> ' + totalCount + ' total changes, ' + highRiskCount + ' high-risk</p>');
}Be careful with nested GlideRecord queries inside loops - they can quickly become performance killers with large change volumes. Consider using GlideAggregate for counts and summaries, or move complex analysis to a scheduled Script Include that pre-calculates weekly summaries. Also verify that your date queries account for timezone differences between the notification server and user preferences.
Service Catalog Approval with Dynamic Approver Chain
Complex catalog requests need approval notifications that show the complete approval chain, current position, and estimated completion timeline. Simple field substitution cannot provide the business context approvers need to make informed decisions.
${mailto:
// Build dynamic approval chain visualization for catalog requests
var request = current; // This is the sc_req_item or sc_request
var approvals = new GlideRecord('sysapproval_approver');
approvals.addQuery('sysapproval', request.sys_id);
approvals.orderBy('order');
approvals.query();
template.print('<h3>Approval Required: ' + request.cat_item.name + '</h3>');
template.print('<p><strong>Requested by:</strong> ' + request.opened_by.name + ' (' + request.opened_by.department + ')</p>');
template.print('<p><strong>Estimated Cost:</strong> $' + (request.price || '0') + '</p>');
// Show approval chain with current position highlighted
template.print('<h4>Approval Chain:</h4>');
template.print('<ol>');
var currentApproval = null;
while (approvals.next()) {
var status = approvals.state.toString();
var approverName = approvals.approver.name || approvals.group.name || 'Unknown';
// Highlight current pending approval
if (status == 'requested' && !currentApproval) {
currentApproval = approvals.sys_id.toString();
template.print('<li><strong style="background-color: yellow;">' + approverName + ' (PENDING - YOU ARE HERE)</strong></li>');
} else if (status == 'approved') {
template.print('<li>✅ ' + approverName + ' (Approved ' + approvals.sys_updated_on.getDisplayValue() + ')</li>');
} else if (status == 'rejected') {
template.print('<li>❌ ' + approverName + ' (Rejected)</li>');
} else {
template.print('<li>⏳ ' + approverName + ' (Waiting)</li>');
}
}
template.print('</ol>');
// Add business justification if provided
if (!gs.nil(request.justification)) {
template.print('<h4>Business Justification:</h4>');
template.print('<p><em>' + request.justification + '</em></p>');
}
}Approval workflows can have complex state changes that happen rapidly, so your Email Script might see approval states that have already changed by the time the email is delivered. Consider adding timestamp checks or state validation to ensure the approval information is still accurate. Also be aware that approval groups might resolve to many individual users, potentially creating performance issues if you iterate through large groups within the script.
The Classic Mistake
Attempting to query related records using GlideRecord without checking if the reference field exists or is populated.
// Email script for incident notification
var incident = new GlideRecord('incident');
incident.get(current.sys_id);
// This will break when assigned_to is empty
var user = new GlideRecord('sys_user');
user.get(incident.assigned_to);
// Runtime error: Cannot read property of undefined
template.print('Assigned to: ' + user.name);
template.print('Email: ' + user.email);
template.print('Phone: ' + user.phone);
// Additional queries that may fail
var group = new GlideRecord('sys_user_group');
group.get(incident.assignment_group);
template.print('Group: ' + group.name);This fails catastrophically when assigned_to or assignment_group fields are empty. ServiceNow's email engine will throw a "Cannot read property 'name' of undefined" error, causing the entire notification to fail silently. The email never sends, and you'll see "Email script error" entries in System Log > All with no helpful details. Users expect the notification but never receive it, creating a black hole in your workflow.
// Email script for incident notification with proper null checking
var incident = new GlideRecord('incident');
incident.get(current.sys_id);
// Safe user lookup with fallback
if (incident.assigned_to && !incident.assigned_to.nil()) {
var user = incident.assigned_to.getRefRecord();
template.print('Assigned to: ' + user.getValue('name'));
template.print('Email: ' + user.getValue('email'));
template.print('Phone: ' + user.getValue('phone'));
} else {
template.print('Assigned to: Unassigned');
}
// Safe group lookup
if (incident.assignment_group && !incident.assignment_group.nil()) {
var group = incident.assignment_group.getRefRecord();
template.print('Group: ' + group.getValue('name'));
}Always check reference fields with field.nil() before dereferencing, or use getRefRecord() which returns null safely instead of throwing exceptions.
Performance Rules
- Never execute more than 3
GlideRecordqueries in a single email script. Each query adds 50-200ms latency; over 5 queries causes email processing timeouts and notifications get queued for retry, delaying delivery by minutes. - Use
getRefRecord()instead of separateGlideRecord.get()calls for reference fields. Direct reference field access is cached and executes in under 10ms versus 50-100ms for new queries. - Avoid
GlideAggregateoperations on tables with over 100,000 records. Aggregations in email scripts block the email processing thread and cause system administrators to receive "Email processing delayed" alerts. - Limit
template.print()calls to under 50 per email. Each call adds content to memory buffers; exceeding 50 calls can cause OutOfMemoryError exceptions and email delivery failure. - Never use
whileloops withgr.next()that could process more than 25 records. Email script execution is limited to 30 seconds; longer loops trigger automatic termination and notification failure. - Cache complex string operations using variables instead of recalculating in loops. String concatenation inside
template.print()calls creates memory pressure and slows email rendering by 200-500ms per calculation. - Use
addQuery()with specific conditions rather than filtering in JavaScript. Database-level filtering prevents loading unnecessary records into memory and reduces script execution time from seconds to milliseconds. - Avoid calling
gs.include()or loading Script Includes within email scripts. External script loading adds 100-300ms latency and can cause dependency resolution failures that break email generation silently.
Side Effects & Platform Behavior
- Database queries in email scripts bypass ACLs and field-level read restrictions. Scripts run with elevated system privileges, potentially exposing sensitive data in email content that users couldn't normally access.
- Script execution errors are logged to
sys_logtable with source "EmailScript" but do not prevent the email template from rendering. Failed scripts produce empty content sections rather than error messages. - Email script execution creates session entries in
sys_email_contexttable that persist for 7 days, tracking every variable and template method call for debugging purposes. - Business Rules with "Email" condition do not fire during email script execution. Only database queries through
GlideRecord.get()orGlideRecord.query()trigger "before query" type rules. - Performance Analytics data collection captures email script execution time as "Email Processing Duration" metric. Scripts exceeding 5 seconds appear in system performance reports as bottlenecks.
- Email scripts run in separate thread pools from other server-side scripts. Heavy email script usage consumes dedicated "Email Worker" threads visible in
Thread Pool Monitordiagnostic page. - Workflow activities and Flow actions triggered by records queried in email scripts execute immediately during email processing, potentially causing unexpected automation cascades.
- Email script variables persist across multiple email recipients when using "Send to multiple users" notification settings. Variable values from previous recipients can leak into subsequent emails unless explicitly reset.
- Update Set capture includes email script changes but not the data they query at runtime. Migrating notifications with complex email scripts often requires separate data migration for referenced configuration records.
- Audit records are created for every database modification made within email scripts, even read-only operations that appear to only query data. This includes
sys_auditentries with source "EmailScriptAccess".
Debugging When It Breaks
When email scripts fail, the most common symptom is emails that send with incomplete or missing content sections, not complete delivery failure. Users receive notifications with blank areas where dynamic content should appear, or emails that contain only static template text. The notification record shows "Sent" status, making the problem invisible unless someone manually reviews email content.
Primary debugging location is System Log > All filtered by source "EmailScript" or "NotificationEmailService". Look for JavaScript runtime errors like "Cannot read property of null" or "ReferenceError: variable is not defined". These appear 2-5 minutes after notification triggers due to email processing queue delays. Enable debug logging in Email > Administration > Email Properties by setting "Log Level" to "Debug" for detailed script execution traces.
For testing and validation, use the "Send Test Email" button in notification records with the "Include Debug Information" checkbox enabled. This bypasses normal email queuing and provides immediate feedback with detailed error messages and variable values. Test emails include execution timing and memory usage statistics normally hidden in production sends.
- Verify all reference fields with
.nil()checks before accessing properties - Check
System Log > Allfor JavaScript errors within 5 minutes of test notification - Confirm
currentvariable contains expected record data withgs.log(current.getTableName()) - Validate email template syntax by sending test email with debug information enabled
- Review notification conditions and "When to send" settings to ensure script context matches expectations
- Test with records containing both populated and empty reference fields to catch null pointer exceptions
Quick Reference
- Use
template.print()for output, notgs.print()orreturnstatements - Access triggering record via
currentvariable; access previous values withpreviouson update notifications - Reference field access:
current.assigned_to.getDisplayValue()for display name,current.assigned_to.emailfor related fields - Email scripts execute server-side with system user privileges, bypassing all ACL restrictions
- Date formatting:
gs.formatDate(current.sys_created_on, 'yyyy-MM-dd')for custom date display - Conditional output: wrap
template.print()inifstatements to hide empty sections - HTML escaping:
template.print()automatically escapes HTML; usetemplate.space.raw_valuefor unescaped content - Script execution timeout is 30 seconds; optimize database queries and avoid complex loops
- Test emails bypass notification conditions; use "Send Test Email" with debug enabled for script validation
- Email script changes require notification deactivation and reactivation to take effect in some instances