Created
November 12, 2025 10:11
-
-
Save misje/9be8f6fec60d150876f7b87e1e6c5ef8 to your computer and use it in GitHub Desktop.
Exchange.Server.Alerts.Mail
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Exchange.Server.Alerts.Mail | |
| author: Andreas Misje – @misje | |
| description: | | |
| Send an e-mail when a client flow (with artifacts of interest) has finished. | |
| Cancelled collections and collections with artifacts that do not satisfy | |
| preconditions do not create notifications when they are finished. | |
| Example use cases: | |
| - A collection is created for an offline client and you want to be notified | |
| when it finishes. The DelayThreshold ensures that e-mails are not sent unless | |
| flows complete some time later (i.e. not immediately). | |
| - An e-mail is sent to an auditor for every collection with detailed results | |
| - Send e-mails when flows (of interest) fail | |
| Information that is considered falsy (empty strings, null, 0, empty lists etc.) | |
| are excluded from the details in the e-mail unless KeepEmptyRows is true. | |
| Use [server secrets](https://docs.velociraptor.app/blog/2024/2024-03-10-release-notes-0.72/#secret-management) | |
| for storing SMTP credentials, if needed. Unless you are running version later | |
| than 0.74.5, you need to specify ServerPort and ServerSkipVerify even if you | |
| have set server_port/skip_verify in your secret ([#4340](https://github.com/Velocidex/velociraptor/issues/4340)). | |
| type: SERVER_EVENT | |
| parameters: | |
| - name: Recipients | |
| type: csv | |
| description: | | |
| E-mail addresses that will receive the message. Required unless | |
| NotifyExecutor is true. | |
| default: | | |
| Address | |
| - name: NotifyExecutor | |
| description: | | |
| Send an e-mail to the user executing this flow (in addition to Recipients) | |
| if the username contains an '@' character. | |
| type: bool | |
| default: false | |
| - name: Sender | |
| description: | | |
| Sender e-mail address. Required unless set in secret. Overrides secret | |
| sender address. | |
| - name: HTML | |
| type: bool | |
| description: | | |
| Send an HTML-formatted message instead of plain text. | |
| default: true | |
| - name: ArtifactsToAlertOn | |
| type: regex | |
| description: | | |
| E-mails will only be sent for finished flows with artifact names | |
| matching this regex. | |
| default: .+ | |
| - name: ArtifactsToIgnore | |
| type: regex | |
| description: | | |
| E-mails will not be sent for finished flows with artifact names matching | |
| this regex. If several artifacts are collected, this filter is ignored. | |
| default: >- | |
| ^(Custom\.)?Generic.Client.Info | |
| - name: ClientLabelsToAlertOn | |
| type: regex | |
| description: | | |
| Only send e-mails for clients whose labels match this regex. | |
| default: .+ | |
| - name: ClientLabelsToIgnore | |
| type: regex | |
| description: | | |
| Ignore clients whose labels match this regex. | |
| - name: NotifyHunts | |
| type: bool | |
| description: | | |
| Send e-mails for finished flows that are part of a hunt. This may produce | |
| a lot of notifications, depending on the number of clients that will take | |
| part in the hunt. | |
| - name: NewClientArtifacts | |
| type: regex | |
| description: | | |
| If the flow is completed by a new client and this regex is non-empty, | |
| ignore all other filters and instead include all artifacts matching this | |
| regex within NewClientThreshold seconds. | |
| - name: NewClientThreshold | |
| type: int | |
| description: | | |
| If a client's `first_seen` is no older than this many seconds, the client | |
| is considered new. This is set a metadata field in the e-mail, and it is | |
| also used by the NewClientArtifacts option. | |
| - name: ErrorHandling | |
| type: multichoice | |
| description: | | |
| Select when e-mails should be sent for failed flows. Use to get notifications | |
| when flows in hunts fail, regardless of NotifyHunts, or regardless of artifact | |
| filters. Ignore flows cancelled by a user by default. | |
| choices: | |
| - IncludeHunts | |
| - IgnoreCancelled | |
| - IgnoreArtifactFilters | |
| - IgnoreDelay | |
| default: '["IgnoreCancelled"]' | |
| - name: DelayThreshold | |
| type: int | |
| description: | | |
| Only create notifications if the flow has not finished within a certain | |
| number of seconds since it was created. Setting this to 0 will create an | |
| e-mail for every flow completion. | |
| default: 10 | |
| - name: Secret | |
| description: | | |
| Secret used to configure server, port, username, password and skip_verify. | |
| Required. | |
| default: notify_mail_secret | |
| - name: SendInterval | |
| type: int | |
| description: | | |
| How long to wait (in seconds) between sending e-mails (throttling). | |
| default: 10 | |
| - name: KeepEmptyRows | |
| type: bool | |
| description: | | |
| By default, rows with empty values are removed from the e-mail. In order | |
| to keep the structure of e-mails consistent, empty rows may be kept with this | |
| setting. | |
| - name: ClientMetadata | |
| type: csv | |
| description: | | |
| Include the following client metadata as useful context in the e-mail. If | |
| Alias is set, the metadata key will be renamed to the alias. The metadata | |
| are output as individual rows (considering prefix fields "Client " to make | |
| their meaning clear). | |
| default: | | |
| Field,Alias | |
| - name: IncludeResultTableFrom | |
| type: csv | |
| description: | | |
| Include results from these artifact sources (regex), optionally only selected | |
| columns (regex), and limited by 50 rows. Tables with many columns will not | |
| be displayed correctly. Requires HTML. Prefer IncludeResultAttachmentFrom | |
| default: | | |
| Source,Columns | |
| - name: IncludeResultAttachmentFrom | |
| type: csv | |
| description: | | |
| Include results from these artifact sources (regex) as attachments, optionally | |
| only selected columns (regex). Choose between jsonl and csv fromat with | |
| AttachmentFormat. | |
| default: | | |
| Source,Columns | |
| - name: AttachmentFormat | |
| type: choices | |
| description: | | |
| Whether to include attachments in CSV (may be difficult to parse) or JSONL. | |
| choices: | |
| - csv | |
| - jsonl | |
| default: jsonl | |
| export: | | |
| // If string is NULL, return an empty string instead: | |
| LET NullStr(String) = if(condition=String = NULL, then='', else=String) | |
| LET IsDict(Var) = typeof(x=Var) = '*ordereddict.Dict' | |
| LET RenameMetadata(Metadata, Aliases) = to_dict(item={ | |
| SELECT _value || _key AS _key, | |
| get(item=Metadata, field=_key) AS _value | |
| FROM items(item=Aliases) | |
| }) | |
| // Create either HTML list items or plain-text " - Value" strings for each | |
| // item in Items. If Items is a dict and not an array, the plain-text | |
| // will be of the form " - Key: Value". | |
| LET _BulletListQuery(Items, HTML, KeepEmpty) = SELECT | |
| format(format=if(condition=HTML, | |
| then='<li>%[1]s</li>', | |
| else=if(condition=IsDict(Var=Items), | |
| then=' - %[2]s: %[1]s', | |
| else=' - %[1]s')), | |
| args=(NullStr(String=_value), _key)) AS Row | |
| FROM items(item=Items) | |
| WHERE _value OR KeepEmpty | |
| // Produce a HTML or simple plain-text list from an array. If the array | |
| // contains only one element, the HTML result will not be a list: | |
| LET BulletList(Items, HTML, KeepEmpty) = if( | |
| condition=HTML, | |
| then=if(condition=len(list=Items) > 1, | |
| then=format(format='<ul>%v</ul>', | |
| args=join(array=_BulletListQuery( | |
| Items=Items, | |
| HTML=HTML, | |
| KeepEmpty=KeepEmpty).Row)), | |
| else=Items[0]), | |
| else='\n' + join(array=_BulletListQuery( | |
| Items=Items, | |
| HTML=HTML, | |
| KeepEmpty=KeepEmpty).Row, | |
| sep='\n')) | |
| // Combine a timestamp and a duration string: | |
| LET TimestampString(Timestamp) = if( | |
| condition=Timestamp.Unix, | |
| then=format(format='%v (%v)', | |
| args=(Timestamp.String, humanize(time=Timestamp))), | |
| else='(never)') | |
| // Join an array with ', ', and if it exceeds MaxLength, drop the remaining | |
| // items and add "( + N more)" instead: | |
| LET ShortenStringList(List, MaxLength) = if( | |
| condition=len(list=List) > MaxLength, | |
| then=join(array=List[:MaxLength], sep=', ') + format( | |
| format=' (+ %v more)', | |
| args=len(list=List) - MaxLength), | |
| else=join(array=List, sep=', ')) | |
| // Return unique values from a list: | |
| LET Unique(Items) = items(item=to_dict(item={ | |
| SELECT _value AS _key | |
| FROM foreach(row=Items) | |
| }))._key | |
| // Convert a dict to HTML table rows, alternatively just plain-text with | |
| // "Key: Value": | |
| LET TableRows(Values, HTML, KeepEmpty) = SELECT | |
| format(format=if(condition=HTML, | |
| then='<tr><td>%v</td><td>%v</td></tr>', | |
| else='%v: %v'), | |
| args=(_key, NullStr(String=_value))) AS Row | |
| FROM items(item=Values) | |
| WHERE _value OR KeepEmpty | |
| // Wrap HTML in "table" tags: | |
| LET HTMLTable(Values, KeepEmpty) = format(format='<table>%v</table>', | |
| args=join( | |
| array=TableRows( | |
| Values= | |
| Values, | |
| HTML=true, | |
| KeepEmpty= | |
| KeepEmpty).Row)) | |
| // Create either an HTML table, or alternatively newline-separated | |
| // key–value plain-text strings: | |
| LET Table(Values, HTML, KeepEmpty) = if(condition=HTML, | |
| then=HTMLTable(Values=Values), | |
| else=join( | |
| array=TableRows( | |
| Values= | |
| Values, | |
| HTML=HTML, | |
| KeepEmpty= | |
| KeepEmpty).Row, | |
| sep='\n')) | |
| // Create a more readable dict with artifact parameters arguments, | |
| // using the artifact name as key, and as value, a dict with parameter | |
| // name and values): | |
| LET ArtifactArguments(Specs) = to_dict(item={ | |
| SELECT artifact AS _key, | |
| to_dict(item={ | |
| SELECT key AS _key, | |
| value AS _value | |
| FROM foreach(row=parameters.env) | |
| }) AS _value | |
| FROM foreach(row=Specs) | |
| }) | |
| // Prepend artifact name to each parameter name. This is useful when | |
| // more than one artifact is called, so that we know to which artifact | |
| // the argument belongs to: | |
| LET ArgumentsGrouped(Specs) = to_dict(item={ | |
| SELECT * | |
| FROM foreach(row={ | |
| SELECT _key AS ArtifactName, | |
| _value AS Params | |
| FROM items(item=ArtifactArguments(Specs=Specs)) | |
| }, | |
| query={ | |
| SELECT ArtifactName + '/' + _key AS _key, | |
| _value | |
| FROM items(item=Params) | |
| }) | |
| }) | |
| // When there is just one artifact, we do not have to group arguments by | |
| // artifact, so drop it and create a dict of arguments from the single value | |
| // in the artifact–arguments dict: | |
| LET Arguments(Specs) = to_dict(item={ | |
| SELECT * | |
| FROM foreach(row={ | |
| SELECT _value AS Params | |
| FROM items(item=ArtifactArguments(Specs=Specs)) | |
| }, | |
| query={ | |
| SELECT * | |
| FROM items(item=Params) | |
| }) | |
| }) | |
| // Create either an HTML table or a plain-text bullet list: | |
| LET TableOrBulletList(Values, HTML, KeepEmpty) = if( | |
| condition=Values, | |
| then=if(condition=HTML, | |
| then=Table(Values=Values, HTML=HTML, KeepEmpty=KeepEmpty), | |
| else=BulletList(Items=Values, HTML=HTML, KeepEmpty=KeepEmpty))) | |
| // Create a multi-column (i.e. not just a two-column KV) table with headers: | |
| LET FullTable(Rows, Headers) = if( | |
| condition=Rows, | |
| then=template( | |
| template='''<table> | |
| {{ if .headers }} | |
| <tr> | |
| {{ range .headers }} | |
| <th>{{ . }}</th> | |
| {{ end }} | |
| </tr> | |
| {{ end }} | |
| {{ range $row := .rows }} | |
| <tr> | |
| {{ range $key := $row.Keys }} | |
| <td>{{ Get $row $key }}</td> | |
| {{ end }} | |
| </tr> | |
| {{ end }} | |
| </table>''', | |
| expansion=dict( | |
| headers=if( | |
| condition=get( | |
| field='Headers') != NULL, | |
| then=Headers, | |
| else=items( | |
| item=Rows[0])._key), | |
| rows=Rows))) | |
| // Create an HTML table or a plain-text bullet list of arguments: | |
| LET ArgumentTable(Specs, HTML, KeepEmpty) = TableOrBulletList( | |
| Values=if(condition=len(list=Specs) = 1, | |
| then=Arguments(Specs=Specs), | |
| else=ArgumentsGrouped(Specs=Specs)), | |
| HTML=HTML, | |
| KeepEmpty=KeepEmpty) | |
| // Create an HTML / plain-text link: | |
| LET Link(URL, Name, HTML) = format( | |
| format=if(condition=HTML, | |
| then='<a href="%[1]s">%[2]s</href>', | |
| else='%[2]s (%[1]s)'), | |
| args=(URL, Name)) | |
| LET HuntInfo(flow_id) = SELECT * | |
| FROM foreach(row={ | |
| SELECT hunt_id, | |
| hunt_description | |
| FROM hunts() | |
| }, | |
| query={ | |
| SELECT hunt_id AS HuntId, | |
| hunt_description AS HuntDesc | |
| FROM hunt_flows(hunt_id=hunt_id) | |
| WHERE FlowId = flow_id | |
| }) | |
| LET ArtifactLinks(Artifacts, HTML) = SELECT Link( | |
| URL=link_to( | |
| artifact= | |
| _value, | |
| raw=true), | |
| Name= | |
| _value) AS Link | |
| FROM foreach(row=Artifacts) | |
| LET MailTemplate(Title, Tables, Summary, Footer, Warning) = | |
| template( | |
| template='''<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <style> | |
| body { | |
| font-family: Arial, sans-serif; | |
| background-color: #f6f6f6; | |
| color: #333333; | |
| margin: 0; | |
| } | |
| .email-container { | |
| max-width: 750px; | |
| margin: 20px auto; | |
| background-color: #ffffff; | |
| padding: 20px; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .header { | |
| background-color: {{ .bgcolor }}; | |
| color: #ffffff; | |
| text-align: center; | |
| padding: 20px; | |
| border-radius: 8px 8px 0 0; | |
| font-size: 24px; | |
| margin: 0; | |
| font-weight: normal; | |
| } | |
| .content { | |
| padding: 0 20px; | |
| } | |
| h2 { | |
| font-size: 18px; | |
| font-weight: 600; | |
| color: #555555; | |
| margin-top: 0; | |
| margin-bottom: 10px; | |
| } | |
| .table-container { | |
| margin-top: 5px; | |
| margin-bottom: 20px; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| td, th { | |
| padding: 12px; | |
| border: 1px solid #dddddd; | |
| } | |
| th { | |
| background-color: #f4f4f4; | |
| } | |
| table table { | |
| border-collapse: separate; | |
| border-spacing: 10px 0; | |
| } | |
| table table td { | |
| border: none; | |
| padding: 0; | |
| } | |
| .footer { | |
| text-align: center; | |
| margin-top: 20px; | |
| font-size: 12px; | |
| color: #777777; | |
| } | |
| ul { | |
| padding: 0; | |
| margin: 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="email-container"> | |
| <h1 class="header"> | |
| {{ .title }} | |
| </h1> | |
| <div class="content"> | |
| <p>{{ .summary }}</p> | |
| {{ range $item := .tables.Items }} | |
| {{ if $.includeTableHeaders }}<h2>{{ $item.Key }}</h2>{{ end }} | |
| <div class="table-container">{{ $item.Value }}</div> | |
| {{ end }} | |
| </div> | |
| <div class="footer"> | |
| {{ .footer }} | |
| </div> | |
| </div> | |
| </body> | |
| </html>''', | |
| expansion=dict( | |
| bgcolor=if( | |
| condition=Warning, | |
| then='#DF0B0B', | |
| else='#4CAF50'), | |
| title=Title, | |
| tables=if( | |
| condition=IsDict( | |
| Var=Tables), | |
| then=Tables, | |
| else=dict( | |
| t=Tables)), | |
| includeTableHeaders=IsDict( | |
| Var=Tables), | |
| footer=Footer, | |
| summary=Summary)) | |
| LET Duration = if(condition=execution_duration, | |
| then=format(format='%.1f s', | |
| args=[execution_duration / 1000000000.0])) | |
| LET UploadedBytes = if(condition=total_uploaded_bytes, | |
| then=humanize(bytes=total_uploaded_bytes), | |
| else=0) | |
| // Look up more details about the flows using flows(), since the data | |
| // returned by watch_monitoring() may be incomplete (like the create_time field): | |
| LET FlowInfo(HTML) = dict( | |
| `Flow`=Link( | |
| URL=link_to( | |
| client_id=ClientId, | |
| flow_id=FlowId, | |
| tab='logs', | |
| raw=true), | |
| Name=FlowId, | |
| HTML=HTML), | |
| `Collection created`=TimestampString( | |
| Timestamp=timestamp( | |
| epoch=create_time)), | |
| `Collection started`=if( | |
| condition=start_time, | |
| then=TimestampString( | |
| Timestamp=timestamp( | |
| epoch=start_time))), | |
| `Collection finished`=TimestampString( | |
| Timestamp=FlowFinished), | |
| `Duration`=Duration, | |
| `Creator`=request.creator, | |
| `Requested`=BulletList( | |
| Items=ArtifactLinks( | |
| Artifacts=request.artifacts).Link, | |
| HTML=HTML, | |
| KeepEmpty=KeepEmptyRows), | |
| `Arguments`=ArgumentTable( | |
| Specs=request.specs, | |
| HTML=HTML, | |
| KeepEmpty=KeepEmptyRows), | |
| `With results`=BulletList( | |
| Items=artifacts_with_results, | |
| HTML=HTML, | |
| KeepEmpty=KeepEmptyRows), | |
| `Error`=if( | |
| condition=status, | |
| then=status), | |
| `Urgent`=request.urgent, | |
| `Hunt`=if( | |
| condition=IsHunt, | |
| then=Link( | |
| URL=link_to( | |
| hunt_id=OurHunt[0].HuntId, | |
| raw=true), | |
| Name=OurHunt[0].HuntDesc || OurHunt[0].HuntId, | |
| HTML=HTML)), | |
| `Collected rows`=total_collected_rows, | |
| `Uploaded files`=total_uploaded_files, | |
| `Uploaded bytes`=UploadedBytes) | |
| LET MetadataFieldAliases <= to_dict(item={ | |
| SELECT Field AS _key, | |
| Alias AS _value | |
| FROM ClientMetadata | |
| }) | |
| LET ClientMetadata = RenameMetadata( | |
| Metadata=client_metadata(client_id=ClientId), | |
| Aliases=MetadataFieldAliases) | |
| LET FQDN = client_info(client_id=ClientId).os_info.fqdn | |
| LET NewClient = timestamp(epoch=start_time).Unix - | |
| timestamp(epoch=client_info(client_id=ClientId).first_seen_at).Unix < | |
| get(field='NewClientThreshold', default=60) | |
| LET ClientInfo(HTML) = dict( | |
| `Organisation`=if(condition=OrgInfo.id != 'root', then=OrgInfo.name), | |
| `ID`=Link(URL=link_to(client_id=ClientId, raw=true), | |
| Name=ClientId, | |
| HTML=HTML), | |
| `FQDN`=FQDN, | |
| `System`=client_info(client_id=ClientId).os_info.system, | |
| `New client`=NewClient) + ClientMetadata | |
| sources: | |
| - query: | | |
| // Get basic information about completed flows: | |
| LET CompletedFlows = SELECT timestamp(epoch=Timestamp) AS FlowFinished, | |
| ClientId, | |
| FlowId | |
| FROM watch_monitoring(artifact='System.Flow.Completion') | |
| WHERE ClientId != 'server' | |
| LET Failed = state != 'FINISHED' | |
| LET Cancelled = status =~ '^Cancelled' | |
| LET IncludeHunts = (Failed | |
| AND 'IncludeHunts' IN ErrorHandling) OR NOT FlowId =~ '\.H$' | |
| LET IncludeArtifact = NOT ArtifactsToAlertOn OR request.artifacts =~ | |
| ArtifactsToAlertOn | |
| LET ExcludeArtifact = ArtifactsToIgnore | |
| AND len(list=request.artifacts) = 1 | |
| AND request.artifacts =~ ArtifactsToIgnore | |
| LET IncludeNewClientArtifact = NewClient | |
| AND request.artifacts =~ NewClientArtifacts | |
| LET MatchesFilters = (Failed | |
| AND 'IgnoreArtifactFilters' IN ErrorHandling) OR ( | |
| IncludeArtifact | |
| AND NOT ExcludeArtifact) | |
| LET AboveDelayThreshold = (Failed | |
| AND 'IgnoreDelay' IN ErrorHandling) OR FlowFinished.Unix - | |
| timestamp(epoch=create_time).Unix >= atoi(string=DelayThreshold) | |
| LET IncludeCancelled = NOT 'IgnoreCancelled' IN ErrorHandling OR NOT | |
| Cancelled | |
| LET MatchesLabelFilters = NOT ClientLabelsToAlertOn OR | |
| client_info(client_id=ClientId).labels =~ ClientLabelsToAlertOn OR NOT | |
| ClientLabelsToIgnore OR NOT ClientLabelsToIgnore =~ | |
| client_info(client_id=ClientId).labels | |
| LET MatchesCriteria = (MatchesFilters | |
| AND AboveDelayThreshold | |
| AND IncludeHunts | |
| AND IncludeCancelled | |
| AND MatchesLabelFilters) OR IncludeNewClientArtifact | |
| LET IsHunt = FlowId =~ '\\.H$' | |
| // Get the hunt (if any) this flow is part of: | |
| LET OurHunt = SELECT * | |
| FROM HuntInfo(flow_id=FlowId) | |
| LET OrgInfo <= org() | |
| LET FlowDetails = SELECT ClientId, | |
| FlowId, | |
| *, | |
| FlowInfo(HTML=true) AS FlowHTMLDict, | |
| FlowInfo(HTML=false) AS FlowDict, | |
| ClientInfo(HTML=true) AS ClientHTMLDict, | |
| ClientInfo(HTML=false) AS ClientDict | |
| FROM flows(client_id=ClientId, flow_id=FlowId) | |
| WHERE MatchesCriteria | |
| AND log(message='Flow %v (%v) completed', | |
| args=(FlowId, join(array=request.artifacts, sep=', '))) | |
| LET ResultWord = if(condition=state = 'FINISHED', | |
| then='finished', | |
| else='FAILED') | |
| LET ResultString = if(condition=state = 'FINISHED', | |
| then='finished collecting', | |
| else='FAILED to collect') | |
| LET Summary = format( | |
| format='Client %v (%v) has %v %v. It took %v and returned %v row(s). %v file(s) were uploaded, totalling %v.', | |
| args=(ClientId, FQDN, ResultString, join( | |
| array=request.artifacts, | |
| sep=', '), Duration, total_collected_rows, | |
| total_uploaded_files, humanize( | |
| bytes=total_uploaded_bytes))) | |
| LET Title = format(format='Client collection %v', args=ResultWord) | |
| LET PlainText = format(format='%v\n\n%v\n\n%v', | |
| args=(Title, Summary, Table( | |
| Values=ClientDict + FlowDict, | |
| HTML=false, | |
| KeepEmpty=KeepEmptyRows), )) | |
| LET Footer = format(format='Sent from Velociraptor by %v', | |
| args=ArtifactLinks( | |
| Artifacts='Exchange.Server.Alerts.Mail', | |
| HTML=HTML)[0].Link) | |
| LET WithResults(Filter) = SELECT * | |
| FROM foreach(row=Filter, | |
| query={ | |
| SELECT _value AS Artifact, | |
| Columns || '.+' AS Columns | |
| FROM foreach(row=artifacts_with_results) | |
| WHERE Artifact =~ Source | |
| }) | |
| LET FlowResults = to_dict(item={ | |
| SELECT Artifact + ' results' AS _key, | |
| FullTable(Rows={ | |
| SELECT * | |
| FROM column_filter(query={ | |
| SELECT * | |
| FROM flow_results(client_id=ClientId, flow_id=FlowId, artifact=Artifact) | |
| LIMIT 50 | |
| }, | |
| include=Columns) | |
| }) AS _value | |
| FROM WithResults(Filter=IncludeResultTableFrom) | |
| WHERE _value | |
| }) | |
| LET AttachmentsDir <= if(condition=IncludeResultAttachmentFrom, then=tempdir()) | |
| LET AttachmentPath(Artifact) = format(format='%s/%s.%s', | |
| args=(AttachmentsDir, | |
| regex_replace( | |
| source= | |
| Artifact, | |
| re='/+', | |
| replace='.'), | |
| AttachmentFormat)) | |
| LET CreateAttachment(Artifact, Query) = SELECT * | |
| FROM if(condition=AttachmentFormat = 'jsonl', | |
| then={ | |
| SELECT AttachmentPath(Artifact=Artifact) AS Path | |
| FROM write_jsonl(filename=AttachmentPath(Artifact=Artifact), query=Query) | |
| }, | |
| else={ | |
| SELECT AttachmentPath(Artifact=Artifact) AS Path | |
| FROM write_csv(filename=AttachmentPath(Artifact=Artifact), query=Query) | |
| }) | |
| LET FlowResultsAttachments = SELECT | |
| dict(Path=Path, | |
| Filename=regex_replace(source=basename(path=Path), | |
| re='/+', | |
| replace='.')) AS Path | |
| FROM foreach(row={ | |
| SELECT * | |
| FROM WithResults(Filter=IncludeResultAttachmentFrom) | |
| }, | |
| query={ | |
| SELECT * | |
| FROM CreateAttachment(Artifact=Artifact, | |
| Query={ | |
| SELECT * | |
| FROM column_filter(query={ | |
| SELECT * | |
| FROM flow_results(client_id=ClientId, flow_id=FlowId, artifact=Artifact) | |
| }, | |
| include=Columns) | |
| }) | |
| GROUP BY Path | |
| }) | |
| LET Tables = dict( | |
| `Client details`=Table(Values=ClientHTMLDict, | |
| HTML=true, | |
| KeepEmpty=KeepEmptyRows), | |
| `Flow details`=Table(Values=FlowHTMLDict, | |
| HTML=true, | |
| KeepEmpty=KeepEmptyRows)) + | |
| if(condition=HTML, then=FlowResults(), else=dict()) | |
| // Add some CSS to make the table look at least a bit nice: | |
| LET HTMLBody = MailTemplate(Title=Title, | |
| Tables=Tables, | |
| Summary=Summary, | |
| Footer=Footer, | |
| Warning=state != 'FINISHED') | |
| LET Results = SELECT * | |
| FROM foreach(row=CompletedFlows, query=FlowDetails) | |
| LET CreatorAddrs = if(condition=NotifyExecutor | |
| AND Creator =~ '[^@]+@.+', | |
| then=(Creator, ), | |
| else=[]) | |
| LET Subject = format(format='%v %v %v', | |
| args=(FQDN, ResultString, ShortenStringList( | |
| List=request.artifacts, | |
| MaxLength=1))) | |
| SELECT * | |
| FROM foreach(row=Results, | |
| query={ | |
| SELECT * | |
| FROM Artifact.Custom.Generic.Utils.SendEmail( | |
| Secret=Secret, | |
| Recipients=Unique(Items=Recipients.Address + CreatorAddrs), | |
| Sender=Sender, | |
| HTMLMessage=if(condition=HTML, then=HTMLBody), | |
| PlainTextMessage=PlainText, | |
| FilesToUpload=FlowResultsAttachments.Path, | |
| Subject=Subject, | |
| Period=SendInterval) | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment