import ast from builtins import range import copy import csv from functools import cmp_to_key import json import logging import mimetypes import os import re import socket import sys import subprocess import sendemail_common if sys.version_info >= (3, 0): from io import (BytesIO, TextIOWrapper) import urllib.parse as urllib else: from cStringIO import StringIO BytesIO = StringIO import urllib import time import splunk.clilib.cli_common as cli_common from email import encoders, utils from email.header import Header from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import splunk.safe_lxml_etree as et from mako import template import mako.filters as filters import splunk.entity as entity import splunk.Intersplunk import splunk.mining.dcutils as dcu import splunk.pdf.pdfgen_utils as pu import splunk.search as search import splunk.secure_smtplib as secure_smtplib import splunk.ssl_context as ssl_context from splunk.rest import simpleRequest from splunk.saved import savedSearchJSONIsAlert from splunk.util import normalizeBoolean, unicode, format_local_tzoffset PDF_REPORT_SERVER_TIMEOUT = 600 PDFGEN_SIMPLE_REQUEST_TIMEOUT = 3600 EMAIL_DELIM = re.compile(r'\s*[,;]\s*') EMAIL_FORMAT = re.compile(r'^[^\s@]+@[^\s@,;]+$') CHARSET = "UTF-8" IMPORTANCE_MAP = { "highest": "1 (Highest)", "high" : "2 (High)", "low" : "4 (Low)", "lowest" : "5 (Lowest)", "1" : "1 (Highest)", "2" : "2 (High)", "4" : "4 (Low)", "5" : "5 (Lowest)" } logger = dcu.getLogger() class SendEmailException(Exception): pass def unquote(val): if val is not None and len(val) > 1 and val.startswith('"') and val.endswith('"'): return val[1:-1] return val def numsort(x, y): if y[1] > x[1]: return -1 elif x[1] > y[1]: return 1 else: return 0 def renderTime(results): for result in results: if "_time" in result: try: result["_time"] = time.ctime(float(result["_time"])) except: pass def getEmailList(email): tmpList = EMAIL_DELIM.split(email) emailList = [] for email_item in tmpList: if re.search(EMAIL_FORMAT, email_item): emailList.append(email_item) else: logger.warn("Invalid email recipient=%s, remove recipient=%s from recipients." % (email_item, email_item)) return emailList # SPL-207377 - Normalize email address separators def normalizeEmail(email, field, recipients): emailList = getEmailList(email[field]) recipients.extend(emailList) stripped = ','.join([str(elem) for elem in emailList]) email.replace_header(field, stripped) def mail(email, argvals, ssContent, sessionKey): sender = email['From'] use_ssl = normalizeBoolean(ssContent.get('action.email.use_ssl', False)) use_tls = normalizeBoolean(ssContent.get('action.email.use_tls', False)) server = ssContent.get('action.email.mailserver', 'localhost') username = argvals.get('username', '') password = argvals.get('password', '') recipients = [] if email['To']: normalizeEmail(email, 'To', recipients) if email['Cc']: normalizeEmail(email, 'Cc', recipients) if email['Bcc']: recipients.extend(getEmailList(email['Bcc'])) del email['Bcc'] # delete bcc from header after adding to recipients # Clear leading / trailing whitespace from recipients recipients = [r.strip() for r in recipients] validRecipients = [] if ssContent.get('action.email.allowedDomainList') != "" and ssContent.get('action.email.allowedDomainList') != None: domains = [] domains.extend(EMAIL_DELIM.split(ssContent['action.email.allowedDomainList'])) domains = [d.strip() for d in domains] domains = [d.lower() for d in domains] recipients = [r.lower() for r in recipients] for recipient in recipients: dom = recipient.partition("@")[2] if not dom in domains: logger.error("For subject=%s, email recipient=%s is not among the alowedDomainList=%s in alert_actions.conf file. Removing it from the recipients list." % (ssContent.get('action.email.subject'), recipient, ssContent.get('action.email.allowedDomainList'))) else: validRecipients.append(recipient) else: validRecipients = recipients mail_log_msg = 'Sending email. subject="%s", encoded_subject="%s", results_link="%s", recipients="%s", server="%s"' % ( ssContent.get('action.email.subject'), email['Subject'], ssContent.get('results_link'), str(validRecipients), str(server) ) try: # make sure the sender is a valid email address if sender.find("@") == -1: sender = sender + '@' + socket.gethostname() if sender.endswith("@"): sender = sender + 'localhost' # setup the Open SSL Context sslHelper = ssl_context.SSLHelper() serverConfJSON = sslHelper.getServerSettings(sessionKey) # Pass in settings from alert_actions.conf into context ctx = sslHelper.createSSLContextFromSettings( sslConfJSON=ssContent.get('alertActions'), serverConfJSON=serverConfJSON, isClientContext=True) # send the mail if not use_ssl: smtp = secure_smtplib.SecureSMTP(host=server) else: smtp = secure_smtplib.SecureSMTP_SSL(host=server, sslContext=ctx) if use_tls: smtp.starttls(ctx) if len(username) > 0 and password is not None and len(password) >0: smtp.login(username, password) if ssContent.get('action.email.allowedDomainList') != "" and ssContent.get('action.email.allowedDomainList') != None: if len(validRecipients) == 0: raise Exception("The email domains of the recipients are not among those on the allowed domain list.") # Installed SMTP daemon may not support UTF8 as well as # it may throw other exceptions also. error = sendemail_common.sendEmailWithUTF8(smtp, sender, validRecipients, email.as_string()) if (error is not None): logger.debug('send mail with utf8 failed. retrying without utf8 option. Error: %s', str(error)) smtp.sendmail(sender, validRecipients, email.as_string()) smtp.quit() if ssContent.get('action.email.allowedDomainList') != "" and ssContent.get('action.email.allowedDomainList') != None: if validRecipients != recipients: raise Exception("Not all of the recipient email domains are on the allowed domain list. Sending emails only to %s" % str(validRecipients)) logger.info(mail_log_msg) except Exception as e: logger.error(mail_log_msg) raise def sendEmail(results, settings, keywords, argvals): for key in argvals: if key != 'ssname': argvals[key] = unquote(argvals[key]) namespace = settings['namespace'] owner = settings['owner'] sessionKey = settings['sessionKey'] sid = settings['sid'] ssname = argvals.get('ssname') isScheduledView = False is_stream_malert = normalizeBoolean(argvals.get('is_stream_malert')) # Use this way to GET the correct alert_actions.conf under corresponding app. entityClass = ['alerts', 'alert_actions', 'email'] uri = entity.buildEndpoint( entityClass, namespace=namespace, owner=owner ) responseHeaders, responseBody = simpleRequest(uri, method='GET', getargs={'output_mode':'json'}, sessionKey=sessionKey) alertEmail = json.loads(responseBody) alertContent = alertEmail['entry'][0]['content'] alertContent['allowedDomainList'] = alertContent['allowedDomainList'].strip() if ssname: # populate content with savedsearch if '_ScheduledView__' in ssname or argvals.get('pdfview'): if '_ScheduledView__' in ssname: ssname = ssname.replace('_ScheduledView__', '') else: ssname = argvals.get('pdfview') uri = entity.buildEndpoint( [ 'scheduled', 'views', ssname], namespace=namespace, owner=owner ) isScheduledView = True else: if is_stream_malert: entityClass = ['alerts', 'metric_alerts'] else: entityClass = ['saved', 'searches'] entityClass.append(ssname) uri = entity.buildEndpoint( entityClass, namespace=namespace, owner=owner ) responseHeaders, responseBody = simpleRequest(uri, method='GET', getargs={'output_mode':'json'}, sessionKey=sessionKey) savedSearch = json.loads(responseBody) ssContent = savedSearch['entry'][0]['content'] # set type of saved search if isScheduledView: ssContent['type'] = 'view' elif is_stream_malert or savedSearchJSONIsAlert(savedSearch): ssContent['type'] = 'alert' else: ssContent['type'] = 'report' # remap needed attributes that are not already on the content ssContent['name'] = ssname ssContent['app'] = savedSearch['entry'][0]['acl'].get('app') ssContent['owner'] = savedSearch['entry'][0]['acl'].get('owner') # The footer.text key will always exist for type alert and report. # It may not exist for scheduled views created before 6.1 therefore the schedule view default footer.text # should be set if the key does not exist. # This can be removed once migration has happened to ensure scheduled views always have the footer.text attribute ssContent['action.email.footer.text'] = ssContent.get('action.email.footer.text', "If you believe you've received this email in error, please see your Splunk administrator.\r\n\r\nsplunk>") # The message key will always exist for type alert and report. # It may not exist for scheduled views created before 6.1 therefore the schedule view default message # should be set if the key does not exist. # This can be removed once migration has happened to ensure scheduled views always have the message.view attribute ssContent['action.email.message'] = ssContent.get('action.email.message.' + ssContent.get('type'), 'A PDF was generated for $name$') if normalizeBoolean(ssContent.get('action.email.useNSSubject', False)): ssContent['action.email.subject'] = ssContent['action.email.subject.' + ssContent.get('type')] # prior to 6.1 the results link was sent as the argval sslink, must check both results_link # and sslink for backwards compatibility ssContent['results_link'] = argvals.get('results_link', argvals.get('sslink', '')) if normalizeBoolean(ssContent['results_link']) and normalizeBoolean(ssContent['type']): split_results_path = urllib.splitquery(ssContent.get('results_link'))[0].split('/') view_path = '/'.join(split_results_path[:-1]) + '/' ssType = ssContent.get('type') useGoLink = False if '@go' in split_results_path: useGoLink = True if ssType == 'alert': if is_stream_malert: # SPL-174186 # Note: this will generate link like this: http:///en-US/app/search/analysis_workspace?s=/servicesNS///alerts/metric_alerts/ # but the current MAW working link is this: http:///en-US/app/search/analysis_workspace?s=/servicesNS///saved/searches/ # If MAW UI can create new links for stream alert, this python code will work out of the box. Otherwise, we need to fix it at here. ssContent['view_link'] = view_path + 'analytics_workspace?' + urllib.urlencode({'s': savedSearch['entry'][0]['links'].get('alternate')}) elif useGoLink: ssContent['view_link'] = view_path + '@go?' + urllib.urlencode({'s': savedSearch['entry'][0]['links'].get('alternate'), 'dispatch_view': 'alert'}) else: ssContent['view_link'] = view_path + 'alert?' + urllib.urlencode({'s': savedSearch['entry'][0]['links'].get('alternate')}) elif ssType == 'report': if useGoLink: ssContent['view_link'] = view_path + '@go?' + urllib.urlencode({'s': savedSearch['entry'][0]['links'].get('alternate'), 'sid': sid, 'dispatch_view': 'report'}) else: ssContent['view_link'] = view_path + 'report?' + urllib.urlencode({'s': savedSearch['entry'][0]['links'].get('alternate'), 'sid': sid}) elif ssType == 'view': ssContent['view_link'] = view_path + ssContent['name'] else: ssContent['view_link'] = view_path + 'search' else: if is_stream_malert: entityClass = ['alerts', 'metric_alerts'] else: entityClass = ['saved', 'searches'] entityClass.append('_new') #assumes that if no ssname then called from searchbar or test email uri = entity.buildEndpoint( entityClass, namespace=namespace, owner=owner ) responseHeaders, responseBody = simpleRequest(uri, method='GET', getargs={'output_mode':'json'}, sessionKey=sessionKey) savedSearch = json.loads(responseBody) ssContent = savedSearch['entry'][0]['content'] searchCommandSSContent = { 'type': 'searchCommand', 'view_link': '', 'action.email.subject': 'Splunk Results' } ssContent.update(searchCommandSSContent) if normalizeBoolean(argvals.get('sendtestemail')): ssContent['type'] = 'view' if argvals.get('pdfview'): ssContent['name'] = argvals.get('pdfview') isScheduledView = True if not argvals.get('message'): ssContent['action.email.message'] = 'Search results attached.' ssContent['trigger_date'] = None ssContent['trigger_timeHMS'] = None ssContent['trigger_time'] = argvals.get('trigger_time') if normalizeBoolean(ssContent['trigger_time']): try: triggerSeconds = time.localtime(float(ssContent['trigger_time'])) ssContent['trigger_date'] = time.strftime("%B %d, %Y", triggerSeconds) # %Z is deprecated, it does not work consistently between OSes ssContent['trigger_timeHMS'] = time.strftime("%H:%M:%S ", triggerSeconds) + format_local_tzoffset() except Exception as e: logger.error(e) # layer in arg vals if argvals.get('to'): ssContent['action.email.to'] = argvals.get('to') if argvals.get('bcc'): ssContent['action.email.bcc'] = argvals.get('bcc') if argvals.get('cc'): ssContent['action.email.cc'] = argvals.get('cc') if argvals.get('format'): ssContent['action.email.format'] = argvals.get('format') if argvals.get('from'): ssContent['action.email.from'] = argvals.get('from') if argvals.get('inline'): ssContent['action.email.inline'] = normalizeBoolean(argvals.get('inline')) if argvals.get('sendresults'): ssContent['action.email.sendresults'] = normalizeBoolean(argvals.get('sendresults')) if argvals.get('sendpdf'): ssContent['action.email.sendpdf'] = normalizeBoolean(argvals.get('sendpdf')) if argvals.get('sendpng'): ssContent['action.email.sendpng'] = normalizeBoolean(argvals.get('sendpng')) if argvals.get('pdfview'): ssContent['action.email.pdfview'] = argvals.get('pdfview') if argvals.get('papersize') or ssContent.get('action.email.papersize'): ssContent['action.email.reportPaperSize'] = argvals.get('papersize') or ssContent.get('action.email.papersize') if argvals.get('paperorientation') or ssContent.get('action.email.paperorientation'): ssContent['action.email.reportPaperOrientation'] = argvals.get('paperorientation') or ssContent.get('action.email.paperorientation') if argvals.get('sendcsv'): ssContent['action.email.sendcsv'] = normalizeBoolean(argvals.get('sendcsv')) if argvals.get('allow_empty_attachment'): ssContent['action.email.allow_empty_attachment'] = normalizeBoolean(argvals.get('allow_empty_attachment')) if argvals.get('pdf.logo_path'): ssContent['action.email.pdf.logo_path'] = argvals.get('pdf.logo_path') if argvals.get('escapeCSVNewline'): ssContent['action.email.escapeCSVNewline'] = normalizeBoolean(argvals.get('escapeCSVNewline')) ## if 'server' in arg is different than the one set in alert_action.conf, don't use default credentials. ## SPL-135659 setDefaultUserCredentials = 1 if argvals.get('server'): alert_action_server = ssContent.get('action.email.mailserver', 'localhost') if alertContent.get('allowedDomainList') == "": ssContent['action.email.mailserver'] = argvals.get('server') else: logger.warn("When 'allowedDomainList' is setup, 'server' argument is not accepted in sendemail command. " "The 'server' value is obtained from 'mailserver'=%s in alert_actions.conf." % alertContent.get('mailserver')) assigned_server = ssContent.get('action.email.mailserver', 'localhost') if str(alert_action_server) != str(assigned_server): setDefaultUserCredentials = 0 if argvals.get('subject'): ssContent['action.email.subject'] = argvals.get('subject') if argvals.get('footer'): ssContent['action.email.footer.text'] = argvals.get('footer') if argvals.get('width_sort_columns'): ssContent['action.email.width_sort_columns'] = normalizeBoolean(argvals.get('width_sort_columns')) if argvals.get('message'): ssContent['action.email.message'] = argvals.get('message') else: if ssContent['type'] == 'searchCommand': # set default message for searchCommand emails if ssContent['action.email.sendresults'] or ssContent['action.email.sendpdf'] or ssContent['action.email.sendcsv']: if ssContent.get('action.email.inline') and not(ssContent.get('action.email.sendpdf') or ssContent.get('action.email.sendcsv')): ssContent['action.email.message'] = 'Search results.' else: ssContent['action.email.message'] = 'Search results attached.' else: ssContent['action.email.message'] = 'Search complete.' if argvals.get('priority'): ssContent['action.email.priority'] = argvals.get('priority') if argvals.get('use_ssl'): ssContent['action.email.use_ssl'] = normalizeBoolean(argvals.get('use_ssl')) if argvals.get('use_tls'): ssContent['action.email.use_tls'] = normalizeBoolean(argvals.get('use_tls')) if argvals.get('content_type'): ssContent['action.email.content_type'] = argvals.get('content_type') # parse the result id for per_result_alert if argvals.get('results_file'): dirname, fname = os.path.split(argvals.get('results_file')) if dirname.endswith('per_result_alert'): results_ids = re.findall(r'^tmp_(\d+)\.', fname) if results_ids and len(results_ids) > 0: ssContent['action.per_alert_result_id'] = results_ids[0] logger.debug('Parsed action.per_alert_result_id=%s' % (ssContent['action.per_alert_result_id'])) else: logger.warn('Cannot parse results_id=%s for per result alert' % (fname)) ssContent['graceful'] = normalizeBoolean(argvals.get('graceful', 0)) #if there is no results_link then do not incude it if is_stream_malert or not normalizeBoolean(ssContent.get('results_link')): ssContent['action.email.include.results_link'] = False #need for backwards compatibility format = ssContent.get('action.email.format') if format == 'html' or format == 'plain': ssContent['action.email.format'] = 'table' ssContent['action.email.content_type'] = format elif format == 'text': ssContent['action.email.content_type'] = 'plain' ssContent['action.email.format'] = 'table' elif format == 'pdf': ssContent['action.email.content_type'] = 'html' ssContent['action.email.format'] = 'table' #fetch server info uriToServerInfo = entity.buildEndpoint(['server', 'info']) serverInfoHeaders, serverInfoBody = simpleRequest(uriToServerInfo, method='GET', getargs={'output_mode':'json'}, sessionKey=sessionKey) serverInfo = json.loads(serverInfoBody) serverInfoContent = serverInfo['entry'][0]['content'] #fetch job info jobResponseHeaders = {} jobResponseBody = { 'entry': [ { 'content': {} } ] } if sid: uriToJob = entity.buildEndpoint( [ 'search', 'jobs', sid ], namespace=namespace, owner=owner ) jobResponseHeaders, jobResponseBody = simpleRequest(uriToJob, method='GET', getargs={'output_mode':'json'}, sessionKey=sessionKey) searchJob = json.loads(jobResponseBody) jobContent = searchJob['entry'][0]['content'] else: jobContent = jobResponseBody['entry'][0]['content'] #fetch view info viewResponseHeaders = {} viewResponseBody = { 'entry': [ { 'content': {} } ] } if isScheduledView: uriToView = entity.buildEndpoint( [ 'data', 'ui', 'views', ssContent['name'] ], namespace=namespace, owner=owner ) viewResponseHeaders, viewResponseBody = simpleRequest(uriToView, method='GET', getargs={'output_mode':'json'}, sessionKey=sessionKey) viewResponseBody = json.loads(viewResponseBody) viewContent = viewResponseBody['entry'][0]['content'] if ssContent.get('action.email.allowedDomainList'): ssContent['action.email.allowedDomainList'] = ssContent['action.email.allowedDomainList'].strip() if ssContent.get('action.email.allowedDomainList') != alertContent.get('allowedDomainList'): ssContent['action.email.allowedDomainList'] = alertContent['allowedDomainList'] logger.warn("For alert=%s, the 'allowedDomainList' value is always obtained from alert_actions.conf." "The allowedDomainList=%s" % (ssname, alertContent.get('allowedDomainList'))) if alertContent.get('allowedDomainList') != "": if ssContent.get('action.email.mailserver') != alertContent.get('mailserver'): ssContent['action.email.mailserver'] = alertContent['mailserver'] logger.warn("For alert=%s, if a 'allowedDomainList' is specified, it uses the 'mailserver'=%s in alert_actions.conf." % (ssname, ssContent.get('action.email.mailserver'))) valuesForTemplate = buildSafeMergedValues(ssContent, results, serverInfoContent, jobContent, viewContent, argvals.get('results_file')) realize(valuesForTemplate, ssContent, sessionKey, namespace, owner, argvals) #Creation of the email object that is to be populated in the build #prefixed methods. content_type = ssContent.get('action.email.content_type') isHtmlBasedContentType = content_type in ['html', 'html_pdf'] inline_studio_png = normalizeBoolean(ssContent.get('action.email.sendpng', False)) if isHtmlBasedContentType or inline_studio_png: email = MIMEMultipart('mixed') email.preamble = 'This is a multi-part message in MIME format.' emailBody = MIMEMultipart('alternative') email.attach(emailBody) else: email = emailBody = MIMEMultipart() #make time user readable resultsWithRenderedTime = copy.deepcopy(results) renderTime(resultsWithRenderedTime) #potentially mutate argvals if username/password are empty if setDefaultUserCredentials: setUserCredentials(argvals, settings) #build all the different email components jobCount = getJobCount(jobContent) #attachments must be built before body so body can inlclude errors cause by attachments but #must actually be attached after body toAttach = buildAttachments(settings, ssContent, resultsWithRenderedTime, email, jobCount, argvals) buildPlainTextBody(ssContent, resultsWithRenderedTime, settings, emailBody, jobCount) if isHtmlBasedContentType or inline_studio_png: buildHTMLBody(ssContent, resultsWithRenderedTime, settings, emailBody, email, jobCount, argvals) buildHeaders(argvals, ssContent, email, sid, serverInfoContent) #attach attachments for attachment in toAttach: email.attach(attachment) try: mail(email, argvals, ssContent, sessionKey) except Exception as e: errorMessage = str(e) + ' while sending mail to: ' + ssContent.get("action.email.to") logger.error(errorMessage) results = dcu.getErrorResults(results, ssContent['graceful'], errorMessage) return results def buildSafeMergedValues(ssContent, results, serverInfoContent, jobContent, viewContent, results_file): mergedObject = {} #namespace the keys for key, value in jobContent.items(): mergedObject['token.job.'+key] = value mergedObject['token.search_id'] = jobContent.get('sid') for key, value in ssContent.items(): mergedObject['token.'+key] = value mergedObject['token.name'] = ssContent.get('name') mergedObject['token.app'] = ssContent.get('app') mergedObject['token.owner'] = ssContent.get('owner') for key, value in serverInfoContent.items(): mergedObject['token.server.'+key] = value if viewContent: mergedObject['token.dashboard.title'] = mergedObject['token.dashboard.label'] = viewContent.get('label') if viewContent.get('label') else mergedObject['token.name'] mergedObject['token.dashboard.id'] = mergedObject['token.name'] mergedObject['token.dashboard.description'] = getDescriptionFromXml(viewContent.get('eai:data')) if results: r = results[0] for k in r: if k.startswith('__mv_'): continue if isinstance(r[k], list): mergedObject['token.result.'+k] = ' '.join(map(str,r[k])) else: mergedObject['token.result.'+k] = r[k] mergedObject['token.results.count'] = len(results) mergedObject['token.results.url'] = ssContent.get('results_link') mergedObject['token.results.file'] = results_file return mergedObject def getDescriptionFromXml(xmlString): if xmlString: dashboardNode = et.fromstring(unicode(xmlString).encode('utf-8')) return dashboardNode.findtext('./description') else: return None def realize(valuesForTemplate, ssContent, sessionKey, namespace, owner, argvals): stringsForPost = {} if ssContent.get('action.email.message'): stringsForPost['action.email.message'] = ssContent['action.email.message'] if ssContent.get('action.email.cc'): stringsForPost['action.email.cc'] = ssContent['action.email.cc'] if ssContent.get('action.email.bcc'): stringsForPost['action.email.bcc'] = ssContent['action.email.bcc'] if ssContent.get('action.email.to'): stringsForPost['action.email.to'] = ssContent['action.email.to'] if ssContent.get('action.email.subject'): stringsForPost['action.email.subject'] = ssContent['action.email.subject'] if ssContent.get('action.email.footer.text'): # SPL-144752: allow footer to have no value at all if user choose to not have it. # This can be done by setting footer = " " (note the space in between) if len(ssContent['action.email.footer.text'].strip()) > 0: stringsForPost['action.email.footer.text'] = ssContent['action.email.footer.text'] realizeURI = entity.buildEndpoint([ 'template', 'realize' ]) postargs = valuesForTemplate postargs['output_mode'] = 'json' postargs['conf.recurse'] = 0 try: for key, value in stringsForPost.items(): if len(value.strip()) == 0: logger.warning('Token substitution may fail due to key:%s contains only whitespaces' % key) postargs['name'] = value headers, body = simpleRequest( realizeURI, method='POST', postargs=postargs, sessionKey=sessionKey ) body = json.loads(body) ssContent[key] = body['entry'][0]['content']['eai:data'] except Exception as e: logger.error(e) # SPL-96721: email subject didn't get replaced, reset it to ssname if ssContent.get('action.email.subject') == stringsForPost.get('action.email.subject'): ssContent['action.email.subject'] = "Splunk Alert:"+argvals['ssname'] ssContent['action.email.message'] = ssContent['action.email.message'] + "\n\nNOTE: The dynamic substitution of the email subject failed because of failed token substitution. A generic subject has been used instead. Please check splunkd.log for additional details." def setUserCredentials(argvals, settings): username = argvals.get("username" , "") password = argvals.get("password" , "") # fetch credentials from the endpoint if none are supplied or password is encrypted if (len(username) == 0 and len(password) == 0) or (password.startswith('$1$') or password.startswith('$7$')) : namespace = settings.get("namespace", None) sessionKey = settings['sessionKey'] username, password = getCredentials(sessionKey, namespace) argvals['username'] = username argvals['password'] = password def getJobCount(jobContent): if jobContent.get('statusBuckets') == 0 or (normalizeBoolean(jobContent.get('reportSearch')) and not re.match('sendemail', jobContent.get('reportSearch'))): return jobContent.get('resultCount') else: return jobContent.get('eventCount') # takes header, html, text def buildHeaders(argvals, ssContent, email, sid, serverInfoContent): sender = ssContent.get("action.email.from", "splunk") to = ssContent.get("action.email.to") cc = ssContent.get("action.email.cc") bcc = ssContent.get("action.email.bcc") subject = ssContent.get("action.email.subject") priority = ssContent.get("action.email.priority", '') # use the Header object to specify UTF-8 msg headers, such as subject, to, cc etc email['Subject'] = Header(subject, CHARSET) recipients = [] if to: email['To'] = to if sender: email['From'] = sender if cc: email['Cc'] = cc if bcc: email['Bcc'] = bcc email['Date'] = utils.formatdate(localtime=True) if priority: # look up better name val = IMPORTANCE_MAP.get(priority.lower(), '') # unknown value, use value user supplied if not val: val = priority email['X-Priority'] = val # trace info if ssContent.get('name'): email['X-Splunk-Name'] = ssContent.get('name') if ssContent.get('owner'): email['X-Splunk-Owner'] = ssContent.get('owner') if ssContent.get('app'): email['X-Splunk-App'] = ssContent.get('app') email['X-Splunk-SID'] = sid email['X-Splunk-ServerName'] = serverInfoContent.get('serverName') email['X-Splunk-Version'] = serverInfoContent.get('version') email['X-Splunk-Build'] = serverInfoContent.get('build') def buildHTMLBody(ssContent, results, settings, emailbody, email, jobCount, argvals): messageHTML = re.sub(r'\r\n?|\\r\\n?|\n|\\n', '
\r\n', htmlMessageTemplate().render(msg=ssContent.get('action.email.message'))) body_png = '' resultsHTML = '' metaDataHTML = '' errorHTML = '' if ssContent['type'] == 'view': if normalizeBoolean(ssContent.get('action.email.include.view_link')) and ssContent.get('view_link'): metaDataHTML = htmlMetaDataViewTemplate().render( view_link=ssContent.get('view_link') ) if should_inline_png(ssContent, argvals): try: name = ssContent.get('action.email.pdfview', '') name_png = name + '.png' body_png = inline_image_template().render(cid_name=name_png, name=name) except ValueError as e: logger.error('Failed to inline PNG due to {}'.format(e)) else: if ssContent['type'] != 'searchCommand': metaDataHTML = htmlMetaDataSSTemplate().render( jobcount=jobCount, results_link=ssContent.get('results_link'), include_results_link=normalizeBoolean(ssContent.get('action.email.include.results_link')), view_link=ssContent.get('view_link'), include_view_link=normalizeBoolean(ssContent.get('action.email.include.view_link')), name=ssContent.get('name'), include_search=normalizeBoolean(ssContent.get('action.email.include.search')), ssquery=ssContent.get('search'), include_smaDefinition=normalizeBoolean(ssContent.get('action.email.include.smaDefinition')), smaIndexes=ssContent.get('metric_indexes'), smaFilter=ssContent.get('filter'), smaGroupby=ssContent.get('groupby'), smaCondition=ssContent.get('condition'), smaThreshold=ssContent.get('trigger.threshold'), smaSuppress=ssContent.get('trigger.suppress'), smaEvaluationPerGroup=ssContent.get('trigger.evaluation_per_group'), smaActionPerGroup=ssContent.get('trigger.action_per_group'), smaTriggerPrepare=ssContent.get('trigger.prepare'), alert_type=ssContent.get('alert_type'), include_trigger=normalizeBoolean(ssContent.get('action.email.include.trigger')), include_inline=normalizeBoolean(ssContent.get('action.email.inline')), include_trigger_time=normalizeBoolean(ssContent.get('action.email.include.trigger_time')), trigger_date=ssContent.get('trigger_date'), trigger_timeHMS=ssContent.get('trigger_timeHMS'), ssType=ssContent.get('type'), ) # need to check aciton.email.sendresults for type searchCommand if normalizeBoolean(ssContent.get('action.email.inline')) and normalizeBoolean( ssContent.get('action.email.sendresults')): resultsHTML = htmlResultsTemplate().render( include_results_link=normalizeBoolean(ssContent.get('action.email.include.results_link')), results_link=ssContent.get('results_link'), truncated=normalizeBoolean(settings.get('truncated')), resultscount=len(results), jobcount=jobCount, hasjob=normalizeBoolean(settings.get('sid')) ) format = ssContent.get('action.email.format') if format == 'table': resultsHTML += htmlTableTemplate().render(results=results) elif format == 'raw': resultsHTML += htmlRawTemplate().render(results=results) elif format == 'csv': resultsHTML += htmlCSVTemplate().render(results=results) footerHTML = htmlFooterTemplate().render(footer=ssContent.get('action.email.footer.text'), re=re, filters=filters) errorHTML = htmlErrorTemplate().render(errors=ssContent.get('errorArray')) wrapperHTML = htmlWrapperTemplate().render(body=messageHTML + body_png + metaDataHTML + errorHTML + resultsHTML + footerHTML) emailbody.attach(MIMEText(wrapperHTML, 'html', _charset=CHARSET)) def htmlWrapperTemplate(): return template.Template(''' ${body} ''') def htmlMetaDataSSTemplate(): return template.Template(''' % if view_link and name and include_view_link: % endif % if ssquery and include_search: % endif % if smaIndexes and include_smaDefinition: % endif % if smaFilter and include_smaDefinition: % endif % if smaGroupby and include_smaDefinition: % endif % if smaCondition and include_smaDefinition: % endif % if smaThreshold and include_smaDefinition: % endif % if smaSuppress and include_smaDefinition: % endif % if smaEvaluationPerGroup and include_smaDefinition: % endif % if smaActionPerGroup and include_smaDefinition: % endif % if smaTriggerPrepare and include_smaDefinition: % endif % if include_trigger and name and alert_type and ssType == "alert": % endif % if include_trigger_time and trigger_timeHMS and trigger_date and ssType == "alert": % endif
${ssType.capitalize()}:${name|h}
Search String:${ssquery|h}
metric_indexes:${smaIndexes|h}
filter:${smaFilter|h}
groupby:${smaGroupby|h}
condition:${smaCondition|h}
trigger.threshold:${smaThreshold|h}
trigger.suppress:${smaSuppress|h}
trigger.evaluation_per_group:${smaEvaluationPerGroup|h}
trigger.action_per_group:${smaActionPerGroup|h}
trigger.prepare:${smaTriggerPrepare|h}
Trigger:Saved Search [${name|h}]: ${alert_type} % if alert_type == "number of events": (${jobcount}) % endif
Trigger Time:${trigger_timeHMS} on ${trigger_date}.
% if not include_inline: % if include_results_link: View results % endif % endif ''') def htmlMetaDataViewTemplate(): return template.Template('''

View dashboard

''') def htmlErrorTemplate(): return template.Template(''' % if errors:
% for error in errors:

${error|h}

% endfor % endif ''') def htmlResultsTemplate(): return template.Template('''
% if truncated: %if jobcount:

Only the first ${resultscount} of ${jobcount} results are included below. %else:

Search results in this email have been truncated. %endif %if include_results_link: View all results in Splunk.

%else:

%endif % elif include_results_link: % endif ''') def htmlMessageTemplate(): return template.Template('
${msg|h}
') def inline_image_template(): return template.Template('dashboard image for ${name}') def htmlFooterTemplate(): return template.Template('''
<% footerEscaped = filters.html_entities_escape(footer) %> <% footerBreaks = re.sub(r'\\r\\n?|\\n', '
', footerEscaped) %>

${footerBreaks}

''') def htmlTableTemplate(): return template.Template(''' % if len(results) > 0:
<% cols = [] %> % for key,val in results[0].items(): % if not key.startswith("_") or key == "_raw" or key == "_time": <% cols.append(key) %> % endif % endfor % for result in results: % for col in cols: % endfor % endfor
${key|h}
% if isinstance(result.get(col), list): % for val in result.get(col):
${val|h}
% endfor % else:
${result.get(col)|h}
% endif
% else:
No results found.
% endif ''') def htmlRawTemplate(): return template.Template(''' % if len(results) > 0: % if results[0].get("_raw"):
% for result in results:
${result.get("_raw", "")|h}
% endfor
% else:
The results contain no "_raw" field. Please choose another inline format (csv or table).
% endif % else:
No results found.
% endif ''') def htmlCSVTemplate(): return template.Template(''' % if len(results) > 0:
<% cols = [] %> % for key,val in results[0].items(): % if not key.startswith("_") or key == "_raw" or key == "_time": <% cols.append(key) %> % endif % endfor ${','.join(cols)|h}
% for result in results: <% vals = [] %> % for col in cols: % if isinstance(result.get(col), list): <% vals.append(' '.join(map(str,result.get(col)))) %> % else: <% vals.append(result.get(col))%> % endif % endfor ${','.join(vals)|h}
% endfor
% else:
No results found.
% endif ''') def buildPlainTextBody(ssContent, results, settings, email, jobCount): plainTextMsg = buildPlainTextMessage().render(msg=ssContent.get('action.email.message')) plainResults = '' plainTextMeta = '' plainError = '' if ssContent['type'] == 'view': if normalizeBoolean(ssContent.get('action.email.include.view_link')) and ssContent.get('view_link'): plainTextMeta = buildPlainTextViewMetaData().render( view_link=ssContent.get('view_link') ) plainError = buildPlainTextError().render(errors=ssContent.get('errorArray')) else: if ssContent['type'] != 'searchCommand': plainTextMeta = buildPlainTextSSMetaData().render( jobcount=jobCount, results_link=ssContent.get('results_link'), include_results_link=normalizeBoolean(ssContent.get('action.email.include.results_link')), view_link=ssContent.get('view_link'), include_view_link=normalizeBoolean(ssContent.get('action.email.include.view_link')), name=ssContent.get('name'), include_search=normalizeBoolean(ssContent.get('action.email.include.search')), ssquery=ssContent.get('search'), include_smaDefinition=normalizeBoolean(ssContent.get('action.email.include.smaDefinition')), smaIndexes=ssContent.get('metric_indexes'), smaFilter=ssContent.get('filter'), smaGroupby=ssContent.get('groupby'), smaCondition=ssContent.get('condition'), smaThreshold=ssContent.get('trigger.threshold'), smaSuppress=ssContent.get('trigger.suppress'), smaEvaluationPerGroup=ssContent.get('trigger.evaluation_per_group'), smaActionPerGroup=ssContent.get('trigger.action_per_group'), smaTriggerPrepare=ssContent.get('trigger.prepare'), alert_type=ssContent.get('alert_type'), include_trigger=normalizeBoolean(ssContent.get('action.email.include.trigger')), include_inline=normalizeBoolean(ssContent.get('action.email.inline')), include_trigger_time=normalizeBoolean(ssContent.get('action.email.include.trigger_time')), trigger_date=ssContent.get('trigger_date'), trigger_timeHMS=ssContent.get('trigger_timeHMS'), ssType=ssContent.get('type') ) plainError = buildPlainTextError().render(errors=ssContent.get('errorArray')) # need to check aciton.email.sendresults for type searchCommand if normalizeBoolean(ssContent.get('action.email.inline')) and normalizeBoolean(ssContent.get('action.email.sendresults')): plainResults = plainResultsTemplate().render( include_results_link=normalizeBoolean(ssContent.get('action.email.include.results_link')), results_link=ssContent.get('results_link'), truncated=normalizeBoolean(settings.get('truncated')), resultscount=len(results), jobcount=jobCount, hasjob=normalizeBoolean(settings.get('sid')) ) format = ssContent.get('action.email.format') if format == 'table': plainResults += plainTableTemplate(results, ssContent) elif format == 'raw': plainResults += plainRawTemplate().render(results=results) elif format == 'csv': plainResults += plainCSVTemplate(results) plainFooter = plainFooterTemplate().render(footer=ssContent.get('action.email.footer.text')) email.attach(MIMEText(plainTextMsg + plainTextMeta + plainError + plainResults + plainFooter, 'plain', _charset=CHARSET)) def buildPlainTextSSMetaData(): return template.Template(''' % if view_link and name and include_view_link: ${ssType.capitalize()} Title: ${name} ${ssType.capitalize()} Location: ${view_link} % endif % if ssquery and include_search: Search String: ${ssquery} % endif % if smaIndexes and include_smaDefinition: metric_indexes: ${smaIndexes} % endif % if smaFilter and include_smaDefinition: filter: ${smaFilter} % endif % if smaGroupby and include_smaDefinition: groupby: ${smaGroupby} % endif % if smaCondition and include_smaDefinition: condition: ${smaCondition} % endif % if smaThreshold and include_smaDefinition: trigger.threshold: ${smaThreshold} % endif % if smaSuppress and include_smaDefinition: trigger.suppress: ${smaSuppress} % endif % if smaEvaluationPerGroup and include_smaDefinition: trigger.evaluation_per_group: ${smaEvaluationPerGroup} % endif % if smaActionPerGroup and include_smaDefinition: trigger.action_per_group: ${smaActionPerGroup} % endif % if smaTriggerPrepare and include_smaDefinition: trigger.prepare: ${smaTriggerPrepare} % endif % if include_trigger and name and alert_type and ssType == "alert": % if alert_type == "number of events": Trigger: Saved Search [${name}]: ${alert_type} (${jobcount}) % else: Trigger: Saved Search [${name}]: ${alert_type} %endif % endif % if include_trigger_time and trigger_timeHMS and trigger_date and ssType == "alert": Trigger Time: ${trigger_timeHMS} on ${trigger_date}. % endif % if not include_inline: % if include_results_link: View results: ${results_link} % endif %endif ''') def buildPlainTextViewMetaData(): return template.Template(''' View dashboard: ${view_link} ''') def buildPlainTextMessage(): return template.Template('${msg}') def plainFooterTemplate(): return template.Template(''' ------------------------------------------------------------------------ ${footer} ''') def buildPlainTextError(): return template.Template(''' % if errors: ------------------------------------------------------------------------ % for error in errors: ${error} % endfor % endif ''') def plainResultsTemplate(): return template.Template(''' ------------------------------------------------------------------------ % if truncated: % if jobcount: Only the first ${resultscount} of ${jobcount} results are included below. % else: Search results in this email have been truncated. % endif % if include_results_link: View all results in Splunk: ${results_link} % endif % elif include_results_link: View results in Splunk: ${results_link} % endif ''') # sort columns from shortest to largest def getSortedColumns(results, width_sort_columns): if len(results) == 0: return [] columnMaxLens = {} for result in results: for k,v in result.items(): # ignore attributes that start with "_" if k.startswith("_") and k!="_raw" and k!="_time": continue if isinstance(v, list): v = getLongestString(v) newLen = len(str(v)) oldMax = columnMaxLens.get(k, -1) #initialize the column width to the length of header (name) if oldMax == -1: columnMaxLens[k] = oldMax = len(k) if newLen > oldMax: columnMaxLens[k] = newLen colsAndCounts = [] # sort columns iff asked to if width_sort_columns: colsAndCounts = sorted(columnMaxLens.items(), key=cmp_to_key(numsort)) else: for k,v in results[0].items(): if k in columnMaxLens: colsAndCounts.append([k, columnMaxLens[k]]) return colsAndCounts def getLongestString(valuesList): return max(map(str,valuesList), key=len) def plainTableTemplate(results, ssContent): if len(results) > 0: width_sort_columns = normalizeBoolean(ssContent.get('action.email.width_sort_columns', True)) columnMaxLens = getSortedColumns(results, width_sort_columns) text = "\n" space = " "*8 # output column names for col, maxlen in columnMaxLens: val = col padsize = maxlen - len(val) text += val + ' '*padsize + space rowBorder = "-"*len(text) text += "\n" + rowBorder + "\n" # output each result's values for result in results: # maximum number of multivalue fields in any column for a given result maxFields = -1 for col, maxlen in columnMaxLens: val = result.get(col, "") if isinstance(val, list): maxFields = len(val) if len(val) > maxFields else maxFields val = val[0] padsize = maxlen - len(val) # left justify ALL the columns text += val + ' '*padsize + space text += "\n" # add remaining multivalue items, if any if maxFields > -1: for row in range(1, maxFields): for col, maxlen in columnMaxLens: val = result.get(col, "") if isinstance(val, list) and len(val) > row: val = str(val[row]) else: # no value in this row if it isn't a list val = "" padsize = maxlen - len(val) text += val + ' '*padsize + space text += "\n" text += rowBorder + "\n" else: text = "No results found." return text def plainCSVTemplate(results): text = "" if len(results) > 0: cols = [] for key,val in results[0].items(): if not key.startswith("_") or key == "_raw" or key == "_time": cols.append(key) text = ','.join(cols) +'\n' for result in results: vals = [] for col in cols: val = result.get(col) if isinstance(val, list): val = ' '.join(map(str,val)) vals.append(val) text += ','.join(vals) + '\n' else: text = "No results found." return text def plainRawTemplate(): return template.Template(''' % if len(results) > 0: % if results[0].get('_raw'): % for result in results: ${result.get("_raw", "")}\n % endfor % else: The results contain no "_raw" field. Please choose another inline format (csv or table). % endif % else: No results found. % endif ''') def should_inline_png(ssContent, argvals): sendpng = normalizeBoolean(ssContent.get('action.email.sendpng'), False) no_errors = len(ssContent.get('errorArray', [])) == 0 inline_specified = normalizeBoolean(ssContent.get('action.email.inline'), False) or normalizeBoolean(argvals.get('inline'), False) return sendpng and no_errors and inline_specified def buildAttachments(settings, ssContent, results, email, jobCount, argvals): toAttach = [] ssContent['errorArray'] = [] sendpdf = normalizeBoolean(ssContent.get('action.email.sendpdf', False)) sendcsv = normalizeBoolean(ssContent.get('action.email.sendcsv', False)) sendpng = normalizeBoolean(ssContent.get('action.email.sendpng', False)) allowEmpty = normalizeBoolean(ssContent.get('action.email.allow_empty_attachment', False)) sendresults = normalizeBoolean(ssContent.get('action.email.sendresults', False)) inline = normalizeBoolean(ssContent.get('action.email.inline', False)) inlineFormat= ssContent.get('action.email.format') input_type = ssContent['type'] namespace = settings['namespace'] owner = settings['owner'] sessionKey = settings['sessionKey'] searchid = settings.get('sid') pdfview = ssContent.get('action.email.pdfview', '') subject = ssContent.get("action.email.subject") ssName = ssContent.get("name") server = ssContent.get('action.email.mailserver', 'localhost') results_link = ssContent.get('results_link') paperSize = ssContent.get('action.email.reportPaperSize', 'letter') paperOrientation = ssContent.get('action.email.reportPaperOrientation', 'portrait') pdfLogoPath = ssContent.get('action.email.pdf.logo_path') # Despite being generically called a "view", a pdfview is _always_ the ID of # a splunk dashboard. Also see src/scheduler/SavedSearchAdminHandler.cpp's # definition of getDefaultScheduledView() isDashboard = normalizeBoolean(pdfview) pdf_attachment = None png_attachment = None export_type = '' alertActions = getAlertActions(sessionKey) if alertActions: # Take ssContent first, then fallback to alertActions. ss_filename = ssContent.get('action.email.reportFileName', None) aa_filename = alertActions.get('reportFileName', None) fileName = ss_filename if ss_filename else aa_filename # Ideally we would retrieve the alert actions conf settings in mail() # which is where these settings are used. But we are already making # a REST call to get these settings, so we save these settings for later ssContent['alertActions'] = alertActions else: fileName = None if sendpdf or sendpng: sendtestemail = normalizeBoolean(argvals.get('sendtestemail', False)) # Dashboard PDF export *always* overrides allowEmpty (action.email.allow_empty_attachment) if len(results) == 0 and not allowEmpty and not sendtestemail and not isDashboard: logger.info("Not attaching pdf file due to no matching results and allow_empty_attachment=%s" % str(allowEmpty)) else: import splunk.pdf.availability as pdf_availability pdfgen_available = pdf_availability.is_available(session_key=sessionKey) logger.info("pdfgen_available = %s" % pdfgen_available) try: if pdfgen_available: result_id = None if ssContent.get('action.per_alert_result_id'): result_id = ssContent.get('action.per_alert_result_id') if sendpdf: export_type = 'pdf' # will raise an Exception on error pdf_attachment = generate_attachment(server, subject, searchid, settings, pdfview, ssName, paperSize, paperOrientation, pdfLogoPath, result_id, export_type, ssContent['errorArray']) if sendpng: export_type = 'png' png_attachment = generate_attachment(server, subject, searchid, settings, pdfview, ssName, paperSize, paperOrientation, pdfLogoPath, result_id, export_type, ssContent['errorArray']) except Exception as e: logger.error("An error occurred while generating a %s: %s" % (export_type.upper(), e)) if len(ssContent['errorArray']) == 0: # default error message (should never reach here for Studio dashboards) base_message = "An error occurred while generating the %s." % export_type.upper() pdf_message = base_message + " " + "See python.log and pdfgen.log for errors." png_message = base_message + " " + "Only Dashboard Studio supports PNG exports. Migrate to Dashboard Studio to schedule a PNG export." error_message = png_message if export_type == 'png' else pdf_message ssContent['errorArray'].append(error_message) # build up filename to use with attachments props = { "owner": owner or 'nobody', "app": namespace, "type": "dashboard" if isDashboard else input_type or "report", "name": pdfview or ssName } if pdf_attachment: pdf_filename = pu.makeReportName(pattern=fileName, type='pdf', reportProps=props) pdf_attachment.add_header('content-disposition', 'attachment', filename=pdf_filename) toAttach.append(pdf_attachment) if png_attachment: png_filename = pu.makeReportName(pattern=fileName, type='png', reportProps=props) extension = '.png' content_id = "<" + pdfview + extension + ">" png_attachment.add_header('Content-Type', mimetypes.types_map[extension]) png_attachment.add_header("Content-Disposition", "inline; filename=" + png_filename) if should_inline_png(ssContent, argvals): # attaches inline png_attachment.add_header("Content-ID", content_id) email.attach(png_attachment) # attaches externally toAttach.append(png_attachment) # (input_type == searchCommand and sendresults and not inline) needed for backwards compatibility # (sendresults and not(sendcsv or sendpdf or inline) and inlineFormat == 'csv') # needed for backwards compatibility when we did not have sendcsv pre 6.1 SPL-79561 if len(results) == 0 and not allowEmpty: logger.info("Not attaching csv file due to no matching results and allow_empty_attachment=%s" % str(allowEmpty)) elif (sendcsv or (input_type == 'searchCommand' and sendresults and not inline) or (sendresults and not(sendcsv or sendpdf or inline) and inlineFormat == 'csv')): csvAttachment = MIMEBase("text", "csv") # SPL-179427 add choice whether to escape newlines escapeCSVNewline = normalizeBoolean(ssContent.get('action.email.escapeCSVNewline', True)) csvAttachment.set_payload(generateCSVResults(results, escapeCSVNewline)) encoders.encode_base64(csvAttachment) props = { "owner": owner or 'nobody', "app": namespace, "type": input_type, "name": ssName } filename = pu.makeReportName(pattern=fileName, type="csv", reportProps=props) csvAttachment.add_header('Content-Disposition', 'attachment', filename=filename) toAttach.append(csvAttachment) if normalizeBoolean(settings.get('truncated')): if normalizeBoolean(len(results)) and normalizeBoolean(jobCount): ssContent['errorArray'].append("Only the first %s of %s results are included in the attached csv." %(len(results), jobCount)) else: ssContent['errorArray'].append("Attached csv results have been truncated.") return toAttach def esc(val): return val.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") def generateCSVResults(results, escapeCSVNewline): if len(results) == 0: return 'No matching events found.' header = [] s = BytesIO() if sys.version_info >= (3, 0): # SPL-204962 Scheduled Jobs sendemail csv file Generated Extra Blank Rows # Per Python 3 csv documentation https://docs.python.org/3/library/csv.html, csv file objects should be opened with newline=''. # # Also in the **footnotes** of the documentation # If newline='' is not specified, newlines embedded inside quoted fields will not be interpreted correctly, # and on platforms that use \r\n linendings on write an extra \r will be added. It should always be safe to specify # newline='', since the csv module does its own (universal) newline handling. t = TextIOWrapper(s, write_through = True, encoding='utf-8', errors='backslashreplace', newline='') w = csv.writer(t) else: w = csv.writer(s) if "_time" in results[0] : header.append("_time") if "_raw" in results[0] : header.append("_raw") # for backwards compatibility remove all internal fields except _raw and _time for k in results[0].keys(): if k.startswith("_") : continue header.append(k) w.writerow(header) # output each result's values for result in results: row = [] for col in header: val = result.get(col,"") if isinstance(val, list): val = ' '.join(map(str,val)) if (escapeCSVNewline): row.append(esc(val)) else: row.append(val) w.writerow(row) return s.getvalue() # call separated for testing purposes def call_pdfgen_render(sessionKey, parameters): response, content = simpleRequest("pdfgen/render", sessionKey = sessionKey, postargs = parameters, timeout = PDFGEN_SIMPLE_REQUEST_TIMEOUT) return response, content def handle_png_error_messages(content, export_type, error_array): if export_type != 'png': return error_messages = get_error_messages(content) if error_messages is not None: error_array.extend(error_messages) def attempt_json_formatting(decoded_content): try: formatted_json_content = json.dumps(ast.literal_eval(decoded_content)) json_data = json.loads(formatted_json_content) return json_data.get('error_messages', None) except (ValueError, TypeError, json.JSONDecodeError) as e: logger.error("Failed to format JSON for error message due to %s" % repr(e)) return None def get_error_messages(content: bytes): try: decoded_content = content.decode('utf-8') json_data = json.loads(content) return json_data.get('error_messages', None) except (UnicodeDecodeError, json.JSONDecodeError): return attempt_json_formatting(decoded_content) def generate_attachment(serverURL, subject, sid, settings, pdfViewID, ssName, paperSize, paperOrientation, pdfLogoPath, resultId, export_type, error_array): """ Reach out and retrieve a PDF copy of the search results if possible and return the MIME attachment """ sessionKey = settings.get('sessionKey', None) owner = settings.get('owner', 'nobody') upper_export_type = export_type.upper() if not sessionKey: raise SendEmailException("Can't attach %s - sessionKey unavailable" % upper_export_type) # build up parameters to the PDF server parameters = {} parameters['namespace'] = settings["namespace"] parameters['owner'] = owner if pdfViewID: parameters['input-dashboard'] = pdfViewID if export_type == 'png': parameters['type'] = export_type else: if ssName: parameters['input-report'] = ssName parameters['timezone'] = settings.get('timezone') elif sid: # in the event where sendemail is called from search # and we need to generate pdf re-run the search job = search.getJob(sid, sessionKey=sessionKey) jsonJob = job.toJsonable(timeFormat='unix') searchToRun = jsonJob.get('search').strip() if searchToRun.lower().startswith('search '): searchToRun = searchToRun[7:] sendemailRegex = r'\|\s*sendemail' if (re.findall(sendemailRegex, searchToRun)): parameters['input-search'] = re.split(sendemailRegex, searchToRun)[0] parameters['et'] = jsonJob.get('earliestTime') parameters['lt'] = jsonJob.get('latestTime') else: raise SendEmailException("Can't attach %s - ssName and pdfViewID unavailable" % upper_export_type) if sid: if type(sid) is dict: for sidKey in sid: parameters[sidKey] = sid[sidKey] else: parameters['sid'] = sid if paperSize and len(paperSize) > 0: if paperOrientation and paperOrientation != "portrait": parameters['paper-size'] = "%s-%s" % (paperSize, paperOrientation) else: parameters['paper-size'] = paperSize if pdfLogoPath: parameters['pdf.logo_path'] = pdfLogoPath if resultId: parameters['result_id'] = resultId # determine if we should set an effective dispatch "now" time for this job scheduledJobEffectiveTime = getEffectiveTimeOfScheduledJob(settings.get("sid", "")) logger.info("sendemail:mail effectiveTime=%s" % scheduledJobEffectiveTime) if scheduledJobEffectiveTime != None: parameters['now'] = scheduledJobEffectiveTime try: response, content = call_pdfgen_render(sessionKey, parameters) except splunk.SplunkdConnectionException as e: raise SendEmailException("Failed to fetch %s (SplunkdConnectionException): %s" % (upper_export_type, repr(e))) except Exception as e: raise SendEmailException("Failed to fetch %s (Exception type=%s): %s" % (upper_export_type, repr(type(e)), repr(e))) if response['status']!='200': raise SendEmailException("Failed to fetch %s (status = %s): %s" % (upper_export_type, repr(response['status']), repr(content))) if export_type == 'png' and response['content-type'] == 'application/json': handle_png_error_messages(content, export_type, error_array) return None if (export_type == 'pdf' and response['content-type']!='application/pdf') or (export_type == 'png' and response['content-type']!='image/png'): raise SendEmailException("Failed to fetch %s (content-type = %s): %s" % (upper_export_type, repr(response['content-type']), repr(content))) mpart = None if export_type == 'png': mpart = MIMEImage(content, _subtype=export_type) else: mpart = MIMEApplication(content, export_type) logger.info('Generated %s for email' % upper_export_type) return mpart def getEffectiveTimeOfScheduledJob(scheduledJobSid): """ parse out the effective time from the sid of a scheduled job if no effective time specified, then return None scheduledJobSid is of form: scheduler______at__ """ scheduledJobSidParts = scheduledJobSid.split("_") effectiveTime = None if "scheduler" in scheduledJobSidParts and len(scheduledJobSidParts) > 4 and scheduledJobSidParts[-3] == "at": secondsStr = scheduledJobSidParts[-2] try: effectiveTime = int(secondsStr) except: pass return effectiveTime def getCredentials(sessionKey, namespace): try: ent = entity.getEntity('admin/alert_actions', 'email', namespace=namespace, owner='nobody', sessionKey=sessionKey) if 'auth_username' in ent and 'clear_password' in ent: encrypted_password = ent['clear_password'] splunkhome = os.environ.get('SPLUNK_HOME') if splunkhome == None: logger.error('getCredentials - unable to retrieve credentials; SPLUNK_HOME not set') return None clear_password = cli_common.decrypt(encrypted_password, setEnv=True) return ent['auth_username'], clear_password except Exception as e: logger.error("Could not get email credentials from splunk, using no credentials. Error: %s" % (str(e))) return '', '' def getAlertActions(sessionKey): settings = None try: settings = entity.getEntity('/configs/conf-alert_actions', 'email', sessionKey=sessionKey) logger.debug("sendemail.getAlertActions conf file settings %s" % settings) except Exception as e: logger.error("Could not access or parse email stanza of alert_actions.conf. Error=%s" % str(e)) return settings def sendHealthAlertEmail(results, settings): """ This function will only be called to send health report alerting emails. Email parameters will be passed through settings/results. """ if not results or len(results) < 1: logger.error("There is no email content specified.") return if not settings.get('to') and not settings.get('cc') and not settings.get('bcc'): logger.error("There are no recipients specified") return sessionKey = "" alertConfig = {} email = MIMEMultipart() if settings.get('to'): email['To'] = settings.get('to') if settings.get('cc'): email['Cc'] = settings.get('cc') if settings.get('bcc'): email['Bcc'] = settings.get('bcc') if settings.get('from'): email['From'] = settings.get('from') alertConfig['action.email.use_ssl'] = normalizeBoolean(settings.get('use_ssl')) alertConfig['action.email.use_tls'] = normalizeBoolean(settings.get('use_tls')) if settings.get('mailserver'): alertConfig['action.email.mailserver'] = settings.get('mailserver') if settings.get('auth_username'): argvals['username'] = settings.get('auth_username') if settings.get('auth_password'): argvals['password'] = settings.get('auth_password') email['Subject'] = Header(results[0].get('subject'), CHARSET) plainMsg = results[0].get('plain_msg') email.attach(MIMEText(plainMsg, 'plain', _charset=CHARSET)) try: mail(email, argvals, alertConfig, sessionKey) except Exception as e: errorMessage = 'Error sending Health Report alert. Error="%s".' % e logger.error(errorMessage) # For health report email alert, don't need return results. return None def createOrganizedResultBuffer(buffer): # creates the readable buffer for reading search results into the send email script return TextIOWrapper(buffer, encoding='utf-8', errors='backslashreplace') if __name__ == '__main__': if sys.version_info >= (3, 0): # We need to support data of non utf-8 encodings coming into this script, to do that, # we allow utf-8 errors input_buf = createOrganizedResultBuffer(sys.stdin.buffer) else: input_buf = sys.stdin results, dummyresults, settings = splunk.Intersplunk.getOrganizedResults(input_buf) try: keywords, argvals = splunk.Intersplunk.getKeywordsAndOptions(CHARSET) logger.debug('SENDEMAIL keywords: %s, argvals: %s' % (keywords, argvals)) if 'is_health_alert' in argvals: results = sendHealthAlertEmail(results, settings) else: if results or 'ssname' in argvals: results = sendEmail(results, settings, keywords, argvals) elif 'sendtestemail' in argvals: if normalizeBoolean(argvals.get('sendtestemail')): results = sendEmail(results, settings, keywords, argvals) else: logger.warn("search results is empty, no email will be sent") except Exception as e: logger.exception(e) splunk.Intersplunk.outputResults(results)