Generic.Utils.SendEmail
Generic.Utils.SendEmail
A Utility artifact for sending emails.
This artifact handles the challenges of MIME, encodings and other pitfalls of sending anything but simple plain-text emails. It will, among other things,
- Let you provide both HTML and plain-text email bodies, letting the email client pick either HTML or plain-text, depending on what it supports (utilising “multipart/alternative”)
- Text is encoded as Base64 (unless disabled), split into 76-character-wide lines in order to conform with RFC standards
- Attachments are supported and automatically encoded
- The whole email is sent as a multi-part message
All of the functions used to create the final body of the email are exported and are available for further customization when sending an email.
name: Generic.Utils.SendEmail
author: Andreas Misje – @misje
description: |
A Utility artifact for sending emails.
This artifact handles the challenges of MIME, encodings and other pitfalls
of sending anything but simple plain-text emails. It will, among other things,
- Let you provide both HTML and plain-text email bodies, letting the email
client pick either HTML or plain-text, depending on what it supports (utilising
"multipart/alternative")
- Text is encoded as Base64 (unless disabled), split into 76-character-wide
lines in order to conform with RFC standards
- Attachments are supported and automatically encoded
- The whole email is sent as a multi-part message
All of the functions used to create the final body of the email are exported
and are available for further customization when sending an email.
type: SERVER
parameters:
- name: Secret
description: The name of the secret to use to send the mail with.
- name: Recipients
type: json_array
default: '["noone@example.org"]'
description: Where to send the mail to.
- name: Sender
description: The sender address (from).
- name: FilesToUpload
type: csv
description: Files to upload, optionally renamed.
default: |
Path,Filename
- name: PlainTextMessage
description: A plain-text message.
- name: HTMLMessage
description: An HTML-formatted message.
- name: EncodeText
type: bool
default: true
description: |
Base64-encode plain-text and HTML. If disabled, ensure to keep lines within
998 octets, or encode the data manually and include an encoding header.
- name: Subject
default: A message from Velociraptor
- name: Period
type: int
default: 10
description: |
Refuse to send mails more often than this interval (in seconds). This throttling
is applied to the whole server.
- name: UseSimpleBoundary
type: bool
description: |
Use a barrier consisting of only [A-Za-z0-9] characters. Some e-mail clients
do not support / conform to the RFC 2045 standard, and cannot handle
boundaries with characters other than simple lower- and upper-case letters,
as well as numbers (i.e not ['()+_,./:=?']).
export: |
LET _RandomString = SELECT format(format="%c", args=20 + rand(range=107)) AS Ch
FROM range(end=1000)
WHERE Ch =~ if(condition=UseSimpleBoundary,
then="[A-Za-z0-9]",
else="[A-Za-z0-9'()+_,./:=?]")
LIMIT 70
-- Create a random string suitable as a MIME boundary:
LET RandomString = join(array=_RandomString.Ch)
-- Base64-encode data and split the result into 76-character long lines (as per
-- RFC 2045 6.8). Note that a "Content-Transfer-Encoding: base64" header is
-- needed for this message to interpreted correctly:
LET EncodeData(Data) = regex_replace(re="(.{76})",
replace="$1\r\n",
source=base64encode(string=Data))
-- Wrap Sections in boundaries. Header may be used to create a sub-boundary,
-- useful for multipart/alternative:
LET WrapInBoundary(Boundary, Sections, Header="") = template(
template="{{ if .header }}{{ .header }}; boundary={{ .boundary }}\r\n\r\n{{ end }}{{ range .sections }}--{{ $.boundary }}\r\n{{ . }}{{ end }}--{{ $.boundary }}--\r\n",
expansion=dict(
boundary=Boundary,
sections=Sections,
header=Header))
-- Add content type ("plain" or "html") and newlines to text. If Encode is set,
-- encode the text in Base64 and add a suitable transfer header:
LET WrapText(Value, Type, Encode=false) = if(
condition=Value,
then=format(
format='Content-Type: text/%s; charset="utf-8"%s\r\n\r\n%v\r\n',
args=[Type, if(condition=Encode,
then="\r\nContent-Transfer-Encoding: base64",
else=""), if(
condition=Encode,
then=EncodeData(Data=Value),
else=Value)]))
-- Wrap text (plain, HTML or both) in multipart/alternative, letting clients
-- pick either HTML or plain-text, depending on what they support. If just
-- one of Plain/HTML is specified, multipart/alternative is not used:
LET WrapAlternative(Plain, HTML) = if(
condition=Plain
AND HTML,
then=WrapInBoundary(Header="Content-Type: multipart/alternative",
Boundary=RandomString,
Sections=(Plain, HTML)),
else=Plain || HTML)
-- Encodes the file as base64:
LET EncodeFile(Filename) = EncodeData(Data=read_file(filename=Filename))
-- A Helper function to embed a file content from disk.
LET AttachFile(Path, Filename) = template(
template='Content-Type: application/octet-stream; name="{{ .name }}"\r\nContent-Disposition: attachment; filename="{{ .filename }}"\r\nContent-Transfer-Encoding: base64\r\n\r\n{{ .data }}\r\n\r\n',
expansion=dict(
name=regex_replace(
source=basename(
path=Filename),
re='''\..+$''',
replace=''),
filename=basename(
path=Filename),
data=EncodeFile(
Filename=Path)))
-- Call AttachFile() for each file in Files that exist. Files must be an array
-- of dicts with the members "Path" and an optional "Filename", which is used
-- to replace the attachment filename. Useful for temporary files:
LET AttachFiles(Files) = SELECT AttachFile(Path=Path,
Filename=
get(field='Filename',
default=
Path)) AS Part
FROM foreach(row=Files)
WHERE (stat(filename=Path).OSPath
AND log(message="Attaching %v", args=Path, dedup=-1, level='INFO')) OR NOT
log(
message="Fail to attach %v",
args=Path,
dedup=-1,
level='WARN')
sources:
- query: |
LET Texts <= WrapAlternative(Plain=WrapText(
Value=PlainTextMessage,
Type='plain',
Encode=EncodeText),
HTML=WrapText(Value=HTMLMessage,
Type='html',
Encode=EncodeText))
LET Texts <= if(condition=Texts, then=[Texts], else=[])
LET Boundary <= RandomString
LET Headers <= dict(`Content-Type`='multipart/mixed; boundary=' + Boundary)
-- Build the email parts - first the text message, then the attachments.
LET Message <= WrapInBoundary(
Boundary=Boundary,
Sections=Texts + AttachFiles(Files=FilesToUpload).Part)
-- Send the mail
SELECT mail(secret=Secret,
`to`=Recipients,
`from`=Sender,
period=Period,
subject=Subject,
headers=Headers,
`body`=Message) AS Mail
FROM scope()````