Mail

The scrippy-mail module sends emails with SMTP, and reads emails with IMAP and POP3.

Table of content

Extending disk_watch.py

The reports of disk_watch.py are stored in the report directory, and possibly synchronized with a central server. With scrippy-mail, version 1.6.0 can also send the report by email, with a summary in the body and the report as attachment.

The configuration file

Add the [mail] section to /home/luiggi/.local/share/scrippy/conf/disk_watch.conf. The username and password keys are optional: When present, the connection is encrypted with STARTTLS and authenticated.

[mail]
host = smtp.flying.circus
port = 587
sender = disk_watch@flying.circus
recipients = ops@flying.circus, harry.fink@flying.circus
username = disk_watch@flying.circus
password = D34DP4RR0T

The code

Starting from the version 1.5.0 of disk_watch.py, the header declares the new configuration keys and the --mail option, which implies --report:

    @conf:mail|host|str|false
    @conf:mail|port|int|false
    @conf:mail|sender|str|false
    @conf:mail|recipients|str|false
    @args:mail|bool|Send the report by email||false||report|false|false

The module is imported, after scrippy_core:

import scrippy_core
from scrippy_mail.smtp import Client as SmtpClient

The new mail_report() function builds a short summary and sends it with the report attached. As in the Database page, the optional password is registered as secret explicitly:

def mail_report(report, results, status, config):
  recipients = [recipient.strip() for recipient in config.get("mail", "recipients").split(",")]
  username = config.get("mail", "username") if config.has("mail", "username") else None
  password = config.get("mail", "password") if config.has("mail", "password") else None
  if password is not None:
    logger.add_secret(password)
  body = [f"disk_watch report for {socket.gethostname()}: {STATUS[status]}", ""]
  body += [f"- {name}: {STATUS[fs_status]}" for name, _, fs_status in results if fs_status != OK]
  logger.info(f"[+] Sending report to {', '.join(recipients)}")
  with SmtpClient(host=config.get("mail", "host"),
                  port=config.get("mail", "port", "int"),
                  username=username,
                  password=password,
                  starttls=username is not None) as smtp:
    smtp.send(subject=f"[disk_watch] {socket.gethostname()}: {STATUS[status]}",
              body="\n".join(body),
              sender=config.get("mail", "sender"),
              recipients=recipients,
              attachments=[report])

And main() sends the report when the --mail option is used:

    if args.report:
      report_format = args.format or "text"
      content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
      report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
      if config.has("publish", "host"):
        retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
        purge_reports(os.path.dirname(report), retention)
        sync_reports(os.path.dirname(report), config)
      if args.mail:
        mail_report(report, results, max(status for _, _, status in results), config)
Complete script
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
  @author         : Luiggi Vercotti
  @date           : 2026-10-08
  @version        : 1.6.0
  @description    : Monitor filesystem usage

--------------------------------------------------------------------------------
  Update:
  0.1.0  2026-10-08 - Luiggi Vercotti - cre: Script creation
  0.2.0  2026-10-08 - Luiggi Vercotti - evo: Several mount points, log levels
  0.3.0  2026-10-08 - Luiggi Vercotti - evo: Monitoring exit status
  0.4.0  2026-10-08 - Luiggi Vercotti - evo: Configuration file
  0.5.0  2026-10-08 - Luiggi Vercotti - evo: Command line options
  0.6.0  2026-10-08 - Luiggi Vercotti - evo: Alerting API token
  0.7.0  2026-10-08 - Luiggi Vercotti - evo: Report file
  0.8.0  2026-10-08 - Luiggi Vercotti - evo: Error handling
  0.9.0  2026-10-08 - Luiggi Vercotti - evo: Access control
  1.0.0  2026-10-08 - Luiggi Vercotti - evo: Concurrent executions
  1.1.0  2026-10-08 - Luiggi Vercotti - evo: Report templates
  1.2.0  2026-10-08 - Luiggi Vercotti - evo: Remote hosts, reports synchronization
  1.3.0  2026-10-08 - Luiggi Vercotti - evo: SNMP appliances
  1.4.0  2026-10-08 - Luiggi Vercotti - evo: Measures database
  1.5.0  2026-10-08 - Luiggi Vercotti - evo: Alerting API
  1.6.0  2026-10-08 - Luiggi Vercotti - evo: Report by email

--------------------------------------------------------------------------------
  List of authorized users or group:
    @group:ops

--------------------------------------------------------------------------------
  Concurrent executions:
    @max_instance: 1
    @exit_on_wait: true

--------------------------------------------------------------------------------
  List of mandatory configuration parameters:
    @conf:filesystems|mount_points|str|false
    @conf:thresholds|warning|int|false
    @conf:thresholds|critical|int|false
    @conf:alerting|url|str|false
    @conf:alerting|token|str|true
    @conf:remote|user|str|false
    @conf:remote|key|str|false
    @conf:snmp|community|str|true
    @conf:snmp|version|str|false
    @conf:mail|host|str|false
    @conf:mail|port|int|false
    @conf:mail|sender|str|false
    @conf:mail|recipients|str|false

--------------------------------------------------------------------------------
  List of execution options and argument parameters:
    @args:host|str|Remote hosts to check over SSH (default: local host)|+||snmp-host||false|false
    @args:snmp-host|str|Appliances to check over SNMP (default: local host)|+||host||false|false
    @args:mount-point|str|Mount points to check (default: from configuration)|+||exclude||false|false
    @args:exclude|str|Mount points to skip|+||||false|false
    @args:warning|int|Warning threshold in percent (default: from configuration)|1||||false|false
    @args:critical|int|Critical threshold in percent (default: from configuration)|1||||false|false
    @args:token|str|Alerting API token (default: from configuration)|1||||false|true
    @args:report|bool|Write a report into the report directory||false|||false|false
    @args:format|choice|Report format (default: text)|1|text,csv,html||report|false|false
    @args:mail|bool|Send the report by email||false||report|false|false

--------------------------------------------------------------------------------
  Functioning:
  ---------------
    Checks the usage of each mount point against a warning and a critical
    threshold and exits with a monitoring compatible exit status:
      0: OK, 1: WARNING, 2: CRITICAL, 3: UNKNOWN (a mount point can not be checked)

    Configuration:
      - filesystems::mount_points: Comma separated list of mount points
      - thresholds::warning: Warning threshold in percent
      - thresholds::critical: Critical threshold in percent
      - alerting::url: Alerting API URL
      - alerting::token: Alerting API token (secret)
      - remote::user: SSH user name on the remote hosts
      - remote::key: SSH private key file
      - snmp::community: SNMP community (secret)
      - snmp::version: SNMP version (1 or 2c)
      - mail::host, mail::port: SMTP server
      - mail::sender: Sender email address
      - mail::recipients: Comma separated list of recipient email addresses
      - mail::username, mail::password: Optional. SMTP authentication
      - publish::host, publish::dir: Optional. Remote host and directory where
        the report directory is synchronized over SFTP (same user and key as
        remote hosts), in a subdirectory named after the local host
      - publish::retention: Optional. Number of days the reports are kept
        locally and on the publication host (default: 30)
      - database::db_type, database::database: Optional. Database type
        (sqlite3, pgsql or mysql) and name where the measures are stored
      - database::username, database::password, database::host,
        database::port: Optional. Database connection settings

    Templates:
      - <templatedir>/disk_watch/report.<format>.j2
"""
# ------------------------------------------------------------------------------
#  Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import shlex
import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file
from scrippy_snmp import ScrippySnmpError
from scrippy_snmp.snmp import Snmp
from scrippy_db.db import Database
from scrippy_api import ScrippyApiError
from scrippy_api.api import Client
from scrippy_mail.smtp import Client as SmtpClient

OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv", "html": "html"}
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"
# HOST-RESOURCES-MIB::hrStorageTable columns
HR_STORAGE_DESCR = "1.3.6.1.2.1.25.2.3.1.3"
HR_STORAGE_SIZE = "1.3.6.1.2.1.25.2.3.1.5"
HR_STORAGE_USED = "1.3.6.1.2.1.25.2.3.1.6"
# Measures database queries: Placeholders depend on the database type
CREATE_TABLE = "CREATE TABLE IF NOT EXISTS measures (date VARCHAR(19), name VARCHAR(255), percent REAL, status VARCHAR(8))"
SELECT_LAST = {"sqlite3": "SELECT percent FROM measures WHERE name = :name ORDER BY date DESC LIMIT 1",
               "pgsql": "SELECT percent FROM measures WHERE name = %(name)s ORDER BY date DESC LIMIT 1",
               "mysql": "SELECT percent FROM measures WHERE name = %(name)s ORDER BY date DESC LIMIT 1"}
INSERT = {"sqlite3": "INSERT INTO measures VALUES(:date, :name, :usage, :status)",
          "pgsql": "INSERT INTO measures VALUES(%(date)s, %(name)s, %(usage)s, %(status)s)",
          "mysql": "INSERT INTO measures VALUES(%(date)s, %(name)s, %(usage)s, %(status)s)"}

# ------------------------------------------------------------------------------
#  Definition of functions and classes
# ------------------------------------------------------------------------------
def get_mount_points(config, args):
  if args.mount_point:
    return args.mount_point
  mount_points = [mp.strip() for mp in config.get("filesystems", "mount_points").split(",")]
  return [mp for mp in mount_points if mp not in (args.exclude or [])]

def get_thresholds(config, args):
  warning = args.warning if args.warning is not None else config.get("thresholds", "warning", "int")
  critical = args.critical if args.critical is not None else config.get("thresholds", "critical", "int")
  if not 0 &lt; warning &lt; critical &lt;= 100:
    raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")
  return warning, critical

def get_local_usage(mount_point):
  logger.debug(f"[+] Checking {mount_point}")
  usage = shutil.disk_usage(mount_point)
  return round(usage.used * 100 / usage.total, 1)

def get_remote_usage(ssh, mount_point):
  logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
  result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
  if result["exit_code"] != 0:
    raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
  # Filesystem 1024-blocks Used Available Capacity Mounted on
  fields = result["stdout"][-1].split()
  return round(int(fields[2]) * 100 / int(fields[1]), 1)

def get_snmp_usages(snmp):
  """Returns the usage of each storage of the appliance, indexed by its description."""
  logger.debug(f"[+] Reading storage table of {snmp.host}")
  columns = {}
  for column in (HR_STORAGE_DESCR, HR_STORAGE_SIZE, HR_STORAGE_USED):
    # OID suffix (storage index) =&gt; value
    columns[column] = {oid.rsplit(".", 1)[1]: value for oid, value in snmp.walk(column)}
  usages = {}
  for index, descr in columns[HR_STORAGE_DESCR].items():
    size = int(columns[HR_STORAGE_SIZE].get(index, 0))
    if size &gt; 0:
      usages[descr] = round(int(columns[HR_STORAGE_USED][index]) * 100 / size, 1)
  return usages

def get_snmp_usage(usages, mount_point):
  try:
    return usages[mount_point]
  except KeyError as err:
    raise OSError(f"No such storage: {mount_point}") from err

def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
  results = []
  for mount_point in mount_points:
    name = f"{prefix}{mount_point}"
    try:
      usage = get_usage(mount_point)
    except OSError as err:
      logger.error(f" '-&gt; {name}: {err.strerror or err}")
      results.append((name, None, UNKNOWN))
      continue
    if usage &gt;= critical:
      logger.error(f" '-&gt; {name}: {usage}% [CRITICAL]")
      status = CRITICAL
    elif usage &gt;= warning:
      logger.warning(f" '-&gt; {name}: {usage}% [WARNING]")
      status = WARNING
    else:
      logger.info(f" '-&gt; {name}: {usage}% [OK]")
      status = OK
    results.append((name, usage, status))
  return results

def check_remote_hosts(hosts, mount_points, warning, critical, config):
  results = []
  for host in hosts:
    logger.info(f"[+] Checking remote host: {host}")
    try:
      with Ssh(username=config.get("remote", "user"),
               host=host,
               key=config.get("remote", "key"),
               missing_host_key_policy="reject") as ssh:
        results += check(mount_points, warning, critical,
                         get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
                         prefix=f"{host}:")
    except ScrippyRemoteError as err:
      logger.error(f" '-&gt; {host}: {err}")
      results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
  return results

def check_snmp_hosts(hosts, mount_points, warning, critical, config):
  results = []
  for host in hosts:
    logger.info(f"[+] Checking SNMP appliance: {host}")
    try:
      snmp = Snmp(host=host,
                  port=161,
                  community=config.get("snmp", "community"),
                  version=config.get("snmp", "version"))
      usages = get_snmp_usages(snmp)
    except ScrippySnmpError as err:
      logger.error(f" '-&gt; {host}: {err}")
      results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
      continue
    results += check(mount_points, warning, critical,
                     get_usage=lambda mount_point: get_snmp_usage(usages, mount_point),
                     prefix=f"{host}:")
  return results

def purge_reports(report_dir, retention):
  """Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
  limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
  logger.info(f"[+] Purging reports older than {retention} days")
  for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
    if name &lt; limit:
      logger.info(f" '-&gt; Deleting {name}")
      delete_local_file(local_file=os.path.join(report_dir, name))

def sync_reports(report_dir, config):
  """Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
  host = config.get("publish", "host")
  remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
  logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
  with Ssh(username=config.get("remote", "user"),
           host=host,
           key=config.get("remote", "key"),
           missing_host_key_policy="reject") as ssh:
    ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
    local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
    remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
    for name in sorted(local_reports - remote_reports):
      logger.info(f" '-&gt; Sending {name}")
      ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
    for name in sorted(remote_reports - local_reports):
      logger.info(f" '-&gt; Deleting {name}")
      ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
    logger.info(f" '-&gt; {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")

def send_alert(results, status, url, token):
  alert = {"source": "disk_watch",
           "host": socket.gethostname(),
           "status": STATUS[status],
           "filesystems": [{"name": name, "usage": usage, "status": STATUS[fs_status]}
                           for name, usage, fs_status in results if fs_status != OK]}
  logger.info(f"[+] Sending alert to {url}")
  try:
    response = Client().request(method="POST",
                                url=f"{url}/alerts",
                                json=alert,
                                headers={"Authorization": f"Bearer {token}"},
                                timeout=10)
    logger.info(f" '-&gt; Alert created: {response.json()['id']}")
  except ScrippyApiError as err:
    # The monitoring status matters more than the alert: Do not stop the script
    logger.error(f" '-&gt; Alert not sent: {err}")

def mail_report(report, results, status, config):
  recipients = [recipient.strip() for recipient in config.get("mail", "recipients").split(",")]
  username = config.get("mail", "username") if config.has("mail", "username") else None
  password = config.get("mail", "password") if config.has("mail", "password") else None
  if password is not None:
    logger.add_secret(password)
  body = [f"disk_watch report for {socket.gethostname()}: {STATUS[status]}", ""]
  body += [f"- {name}: {STATUS[fs_status]}" for name, _, fs_status in results if fs_status != OK]
  logger.info(f"[+] Sending report to {', '.join(recipients)}")
  with SmtpClient(host=config.get("mail", "host"),
                  port=config.get("mail", "port", "int"),
                  username=username,
                  password=password,
                  starttls=username is not None) as smtp:
    smtp.send(subject=f"[disk_watch] {socket.gethostname()}: {STATUS[status]}",
              body="\n".join(body),
              sender=config.get("mail", "sender"),
              recipients=recipients,
              attachments=[report])

def get_database(config):
  connection = {"db_type": config.get("database", "db_type"),
                "database": config.get("database", "database")}
  for key in ("username", "password", "host"):
    if config.has("database", key):
      connection[key] = config.get("database", key)
  if config.has("database", "port"):
    connection["port"] = config.get("database", "port", "int")
  if "password" in connection:
    # Undeclared configuration key: Its value has to be registered as secret explicitly
    logger.add_secret(connection["password"])
  return Database(**connection)

def store_measures(results, config):
  db_type = config.get("database", "db_type")
  date = f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}"
  logger.info("[+] Storing measures")
  with get_database(config) as db:
    db.query(query=CREATE_TABLE, commit=True)
    queries = []
    for name, usage, status in results:
      if usage is None:
        continue
      previous = db.query(query=SELECT_LAST[db_type], params={"name": name})
      if previous:
        logger.info(f" '-&gt; {name}: {usage - previous[0][0]:+.1f}% since last check")
      queries.append({"query": INSERT[db_type],
                      "params": {"date": date, "name": name, "usage": usage, "status": STATUS[status]}})
    db.transaction(queries=queries, exit_on_error=True)

def render_report(results, report_format, warning, critical, templatedir):
  params = {"hostname": socket.gethostname(),
            "date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
            "warning": warning,
            "critical": critical,
            "status": STATUS[max(status for _, _, status in results)],
            "results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
                        for mount_point, usage, status in results]}
  renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
                               f"report.{EXTENSIONS[report_format]}.j2")
  return renderer.render(params)

def write_report(content, report_format, workspace_path, reportdir):
  name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
  tmp_report = os.path.join(workspace_path, name)
  logger.info(f"[+] Writing report: {tmp_report}")
  with open(tmp_report, mode="w", encoding="utf-8") as report:
    report.write(content)
  report_dir = os.path.join(reportdir, "disk_watch")
  os.makedirs(report_dir, exist_ok=True)
  logger.info(f"[+] Publishing report into {report_dir}")
  return shutil.move(tmp_report, os.path.join(report_dir, name))

# ------------------------------------------------------------------------------
#  Main processing
# ------------------------------------------------------------------------------
def main():
  with scrippy_core.ScriptContext(retention=100) as _context:
    config = _context.config
    args = _context.args
    token = args.token or config.get("alerting", "token")
    logger.debug(f"[+] Alerting API token: {token}")
    warning, critical = get_thresholds(config, args)
    logger.log_section("disk_watch", f"Thresholds: warning={warning}% critical={critical}%")
    mount_points = get_mount_points(config, args)
    if args.host:
      results = check_remote_hosts(args.host, mount_points, warning, critical, config)
    elif args.snmp_host:
      results = check_snmp_hosts(args.snmp_host, mount_points, warning, critical, config)
    else:
      results = check(mount_points, warning, critical)
    if config.has("database", "db_type"):
      store_measures(results, config)
    if args.report:
      report_format = args.format or "text"
      content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
      report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
      if config.has("publish", "host"):
        retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
        purge_reports(os.path.dirname(report), retention)
        sync_reports(os.path.dirname(report), config)
      if args.mail:
        mail_report(report, results, max(status for _, _, status in results), config)
    status = max(status for _, _, status in results)
    logger.info(f"[+] Status: {STATUS[status]}")
    if status != OK:
      send_alert(results, status, config.get("alerting", "url"), token)
    if status != OK:
      sys.exit(status)

# ------------------------------------------------------------------------------
#  Entry point
# ------------------------------------------------------------------------------
if __name__ == '__main__':
  main()

The result

./disk_watch.py --report --format html --mail
[...]
[20261008 19:32:53] [INFO    ] [+] Sending report to ops@flying.circus, harry.fink@flying.circus
[20261008 19:32:53] [INFO    ] Connecting to smtp.flying.circus:587
[20261008 19:32:53] [INFO    ] Using STARTTLS
[20261008 19:32:53] [INFO    ] Sending email...
[...]

The recipients receive:

Subject: [disk_watch] srv.flying.circus: WARNING
From: disk_watch@flying.circus
To: ops@flying.circus, harry.fink@flying.circus
User-Agent: Scrippy MUA

disk_watch report for srv.flying.circus: WARNING

- /var: WARNING

[Attachment: disk_watch_20261008_193253.html]

Installation

pip install scrippy-mail

scrippy-mail requires Python 3.11 or later. It only relies on the Python standard library.

Common parameters

The three clients, scrippy_mail.smtp.Client, scrippy_mail.imap.Client and scrippy_mail.pop.Client, accept the following parameters:

Parameter Description Default
host The mail server
port The port of the server 25 (SMTP), 143 (IMAP), 110 (POP3)
username The user name. SMTP does not authenticate when None Mandatory for IMAP and POP3, None for SMTP
password The password Mandatory for IMAP and POP3, None for SMTP
ssl Use an encrypted connection from the start (usually ports 465, 993, 995) False
starttls Upgrade the connection to an encrypted one with STARTTLS (usually ports 587, 143, 110) False
ssl_verify Verify the server certificate and host name True
timeout Connection timeout in seconds 60
exit_on_error See Error handling True

The SMTP client also accepts helo, the local host name sent with HELO/EHLO (default: The fully qualified name of the local host).

  • ssl and starttls are mutually exclusive: Setting both raises a ScrippyMailError.
  • Encrypted connections require TLS 1.2 or later and forward secrecy cipher suites. Servers only supporting older protocols or ciphers are refused.
  • The clients are context managers: The connection is closed when leaving the with block. The SMTP client connects when entering the block, the IMAP and POP3 clients on their first operation.

Tip: Store the password in the configuration file, declared as secret.

Error handling

Operations raise a scrippy_mail.ScrippyMailError on failure.

Connection errors (network, TLS, authentication) are logged as CRITICAL and raised as is: ConnectionRefusedError, ssl.SSLError, smtplib.SMTPAuthenticationError, imaplib.IMAP4.error...

[20261008 19:33:04] [CRITICAL] ConnectionRefusedError [Errno 111] Connection refused
[20261008 19:33:04] [CRITICAL] [ConnectionRefusedError]: [Errno 111] Connection refused

When exit_on_error is False:

  • Connection errors are logged as warnings instead of being raised. Any IMAP or POP3 operation then raises a ScrippyMailError ([ClientConnectionError] Not connected to ...).
  • SMTP sending errors are logged as warnings instead of being raised.
from scrippy_mail import ScrippyMailError
from scrippy_mail.imap import Client as ImapClient

try:
  with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                  username="harry.fink@flying.circus", password=password) as imap:
    logger.info(f"[+] {imap.get_message_count()} emails in INBOX")
except ScrippyMailError as err:   # Operation error
  logger.error(f"[+] {err}")
  raise
except Exception as err:          # Connection error
  logger.error(f"[+] Connection failed: {err}")
  raise

Sending emails with SMTP

from scrippy_mail.smtp import Client as SmtpClient

with SmtpClient(host="smtp.flying.circus", port=465, ssl=True,
                username="luiggi@flying.circus", password=password) as smtp:
  smtp.send(subject="Error report",
            body="The script ended with the following error:\n- This parrot is no more!",
            sender="luiggi@flying.circus",
            recipients=["harry.fink@flying.circus"],
            cc=["arthur.pewtey@flying.circus"],
            bcc=["audit@flying.circus"],
            attachments=["/home/luiggi/.local/share/scrippy/reports/disk_watch/report.csv"])
smtp.send(subject, body, sender, recipients=None, cc=None, bcc=None, attachments=None)
Parameter Description
subject The subject
body The body, as plain text
sender The sender email address
recipients, cc, bcc Lists of email addresses. At least one of them is required. bcc recipients are not visible to the other recipients
attachments A list of file paths. Their MIME type is guessed from their extension

If some recipients are rejected by the server, a ScrippyMailError listing them is raised:

[CRITICAL] [ScrippyMailError]: [SmtpSendError] ScrippyMailError Rejected: <nobody@flying.circus> Reason: [550] No such user

Note: The body is sent as plain text. To send an HTML document, such as a report rendered with scrippy-template, attach it.

Reading emails with IMAP

from scrippy_mail.imap import Client as ImapClient

with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                username="harry.fink@flying.circus", password=password) as imap:
  for number in imap.get_unread_messages_numbers():
    message = imap.get_message(number=number)
    imap.save_message(number=number,
                      filename=os.path.join(_context.workspace_path, f"message-{int(number)}.eml"))
    imap.mark_message_as_read(number=number)

Every method accepts a mailbox argument (default: "INBOX"). Mailbox names may contain spaces and non ASCII characters. Using a mailbox that does not exist raises a ScrippyMailError.

Email numbers are returned by the server as bytes (i.e: b'3'): Pass them as is to the other methods, and convert them with int() to display them.

Reading

Method Returns
get_message_count() The number of emails
get_messages_numbers(tags=None, inverse=False) The numbers of the emails having any of tags, or none of them when inverse is True. All emails when tags is None
get_unread_messages_numbers(), get_read_messages_numbers() The numbers of the unread, read emails
get_message(number) The email as a string
get_messages(tags=None, inverse=False) The emails as strings, filtered as with get_messages_numbers()
save_message(number, filename) See Saving emails
list_mailbox(mailbox="INBOX") The full names of all the sub mailboxes, at any depth
  • Emails are decoded as UTF-8, or with their declared charset.
  • Reading an email does not mark it as read: Use mark_message_as_read() or mark_messages_as_read().

Tags

Tags are IMAP flags: System flags (\Seen, \Flagged, \Answered, \Deleted, \Draft) or keywords (i.e: $Processed).

Method Action
tag_message(number, tags), untag_message(number, tags) Adds, removes tags on an email
tag_messages(tags), untag_messages(tags) Adds, removes tags on every email of the mailbox
mark_message_as_read(number), mark_message_as_unread(number) Adds, removes \Seen on an email
mark_messages_as_read(), mark_messages_as_unread() Adds, removes \Seen on every email of the mailbox
# Process the emails not processed yet
for number in imap.get_messages_numbers(tags=["$Processed"], inverse=True):
  process(imap.get_message(number=number))
  imap.tag_message(number=number, tags=["$Processed"])

Deleting

Method Action
delete_message(number) Deletes an email
delete_messages(tags=None, inverse=False) Deletes the emails filtered as with get_messages_numbers(), all emails of the mailbox when tags is None
  • Deletion is immediate and only affects the specified emails.
  • Deleting an email renumbers the following ones: To delete several emails, use delete_messages() rather than delete_message() in a loop.
imap.delete_messages(tags=["$Processed"])
imap.delete_messages(mailbox="Trash")

Reading emails with POP3

from scrippy_mail.pop import Client as PopClient

with PopClient(host="pop.flying.circus", port=995, ssl=True,
               username="harry.fink@flying.circus", password=password) as pop:
  for number in pop.get_messages_numbers():
    pop.save_message(number=number,
                     filename=os.path.join(_context.workspace_path, f"message-{number}.eml"))
    pop.delete_message(number=number)
Method Returns, action
get_message_count() The number of emails
get_messages_numbers() The numbers of the emails
get_message(number), get_all_messages() The email, all emails as strings
delete_message(number), delete_all_messages() Deletes an email, all emails
save_message(number, filename) See Saving emails
  • POP3 has no mailbox, tag nor read state: Use IMAP for these features.
  • Deletions are applied by the server when leaving the with block. Email numbers do not change until then.

Saving emails

save_message(number, filename) (IMAP and POP3) writes the email as is to filename, and its attachments to the <filename>.attachments/ directory. It returns the paths of the written files:

result = imap.save_message(number=number, filename="/home/luiggi/inbox/message.eml")
# {"filename": "/home/luiggi/inbox/message.eml",
#  "attachments": ["/home/luiggi/inbox/message.eml.attachments/report.pdf",
#                  "/home/luiggi/inbox/message.eml.attachments/report-1.pdf",
#                  "/home/luiggi/inbox/message.eml.attachments/attachment-3.png",
#                  "/home/luiggi/inbox/message.eml.attachments/forwarded.eml"]}
  • Attachments with the same name are suffixed (report-1.pdf).
  • Unnamed attachments are named attachment-<n>, with an extension matching their type.
  • Attached emails are saved as .eml files, their own attachments left inside.
  • Email bodies are not saved as attachments, but named inline parts (i.e: embedded images) are.
  • The attachments directory is only created if the email has attachments.