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 customisation 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 customisation 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.

export: |
  LET _RandomString = SELECT format(format="%c", args=20 + rand(range=107)) AS Ch
    FROM range(end=1000)
    WHERE Ch =~ "[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=get(
          field='Header',
          default='')))

  -- 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) = 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=get(field='Encode', default=false),
                       then="\r\nContent-Transfer-Encoding: base64",
                       else=""), if(
          condition=get(field='Encode', default=false),
          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) = array(_={
     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()