Skip to content

Developer Interface

exceptions

SpoolError

Base class for exceptions.

mailer

Mailer

Represents an SMTP connection.

dump(msg) staticmethod

Print a message to console.

Prints a given message to console in Internet Message Format (IMF).

Parameters:

Name Type Description Default
msg

A message.

required
Source code in spool/mailer.py
185
186
187
188
189
190
191
192
193
194
195
@staticmethod
def dump(msg):
    """Print a message to console.

    Prints a given message to console in Internet Message Format (IMF).

    Args:
        msg: A message.
    """

    print(MAIL_OUT_PREFIX, msg.as_string(), MAIL_OUT_SUFFIX, sep='\n')

get_helo_name() staticmethod

Retrive the helo/ehlo name based on the hostname.

Source code in spool/mailer.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
@staticmethod
def get_helo_name():
    """Retrive the helo/ehlo name based on the hostname."""

    fqdn = socket.getfqdn()
    if '.' in fqdn:
        return fqdn

    # Use a domain literal for the EHLO/HELO verb, as specified in RFC 2821
    address = '127.0.0.1'
    try:
        address = socket.gethostbyname(socket.gethostname())
    except socket.gaierror:
        pass

    return f'[{address}]'

send(self, msg, print_only=True)

Send a message.

Parameters:

Name Type Description Default
msg

The message to send (or print to console)

required
print_only

obj: bool, optional): Whether to print the message to console instead of sending to remote.

True
Source code in spool/mailer.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def send(self, msg, print_only=True):
    """Send a message.

    Args:
        msg: The message to send (or print to console)
        print_only (:obj: `bool`, optional): Whether to print the
            message to console instead of sending to remote.
    """

    if print_only:
        self.dump(msg)

    sender = formataddr(msg.sender)

    recipients = msg.recipients + msg.cc_addrs + msg.bcc_addrs
    recipients = [formataddr(r) for r in recipients]

    if self.relay:
        self._send_message(self.relay, sender, recipients, msg)

    else:
        def domain(address):
            return address.split('@', 1)[-1]

        if self.reorder_recipients:
            recipients = sorted(recipients, key=domain)

        for domain, recipients in itertools.groupby(recipients, domain):

            try:
                host = self._get_remote(domain, self.nameservers)

            except RemoteNotFoundError as err:
                LOG.error(
                    'Failed to send message: %s [name=%s]', err, msg.name)
                continue

            self._send_message(host, sender, list(recipients), msg)

MailerError

Base class for all errors related to the mailer.

RemoteNotFoundError

Remote server could not be evaluated.

main

cli()

Main cli entry point.

Source code in spool/main.py
203
204
205
206
207
208
209
210
211
212
213
214
def cli():
    """Main cli entry point."""
    try:
        run()

    except SpoolError as ex:
        logging.critical(ex)
        sys.exit(1)

    except Exception:
        logging.critical('Unexpected error occured.', exc_info=True)
        sys.exit(1)

parse_args(args)

Parse command line arguments.

Source code in spool/main.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def parse_args(args):
    """Parse command line arguments."""

    parser = argparse.ArgumentParser(description='Send mails with YAML.')

    parser.add_argument(
        '-r', '--relay',
        help='smtp relay smtp server',
    )

    parser.add_argument(
        '-p', '--port',
        type=int, default=25,
        help='port on remote server, default: 25',
    )

    parser.add_argument(
        '-n', '--nameservers',
        help='nameservers for lookup of MX records'
    )

    parser.add_argument(
        '-d', '--delay',
        type=float,
        help='delay delivery by a given number of seconds after each mail'
    )

    parser.add_argument(
        '-D', '--debug',
        action='store_true',
        help='enable debugging on smtp conversation',
    )

    parser.add_argument(
        '-P', '--print-only',
        action='store_true',
        help='print, but do not send messages',
    )

    parser.add_argument(
        '-H', '--helo',
        help='helo name used when connecting to the smtp server',
    )

    parser.add_argument(
        '-c', '--check',
        action='store_true',
        help='check config files and quit',
    )

    parser.add_argument(
        '-t', '--tags',
        help='tags to execute',
    )

    parser.add_argument(
        '--starttls',
        action='store_true',
        help=''
    )

    parser.add_argument(
        'path',
        nargs='+', metavar='config', type=Path,
        help='path of spool config',
    )

    output_group = parser.add_mutually_exclusive_group()

    output_group.add_argument(
        '-v', '--verbose',
        action='count', default=0, dest='verbosity',
        help='verbose output (repeat for increased verbosity)',
    )

    output_group.add_argument(
        '-s', '--silent',
        action='store_const', const=-1, default=0, dest='verbosity',
        help='quiet output (show errors only)',
    )

    return parser.parse_args(args)

run()

Main method.

Source code in spool/main.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def run():
    """Main method."""

    args = parse_args(sys.argv[1:])
    config_logger(args.verbosity)

    first = True

    for path in args.path:

        if not path.is_file():
            LOG.warning('No such file, skipping. [path=%s]', path)
            continue

        try:
            config = Config.load(path)

            if args.check:
                continue

        except ConfigError as ex:
            LOG.error('Error while parsing config: %s [path=%s]', ex, path)
            continue

        with Mailer(relay=args.relay, port=args.port, helo=args.helo,
                    debug=args.debug, nameservers=args.nameservers,
                    starttls=args.starttls) as mailer:

            for mail in config.mails:

                if not tags_matches_mail(args.tags, mail.pop('tags', [])):
                    LOG.debug('Skipping message "%s", does not match tags: %s',
                              mail['name'], args.tags)
                    continue

                if not first and args.delay:
                    LOG.debug('Delay sending of next message by %.2f seconds.',
                              args.delay)
                    time.sleep(args.delay)
                else:
                    first = False

                mail.pop('description', None)
                mail = parse_files(path, mail)
                attachments = mail.pop('attachments', [])

                msg = Message(**mail)

                if isinstance(attachments, str):
                    attachments = [attachments]

                for attachment in attachments:
                    file_path = path.parent / attachment
                    msg.attach(file_path)

                try:
                    mailer.send(msg, args.print_only)

                except MessageError as ex:
                    LOG.error(
                        'Failed to create message: %s. [name=%s, path=%s]',
                        ex, mail['name'], path
                    )

tags_matches_mail(tags, mail)

Returns True if mail has a matching tag.

Source code in spool/main.py
100
101
102
103
104
105
106
107
108
def tags_matches_mail(tags, mail):
    """Returns True if mail has a matching tag."""

    if not tags:
        return True

    tags = [tag.strip() for tag in tags.split(',')]

    return any(tag in mail for tag in tags)

message

EmailHeaders

Case insensitive dictionary to store email headers.

Copied from Requests CaseInsensitiveDict.

.._ Requests:
    https://requests.readthedocs.io

Message

Represents a single email message.

headers property readonly

Get the message headers.

as_string(self)

Return the entire message flattened as a string.

Returns:

Type Description
str

The message as Internet Message Format (IMF) formatted string.

Source code in spool/message.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def as_string(self):
    """Return the entire message flattened as a string.

    Returns:
        str: The message as Internet Message Format (IMF)
            formatted string.
    """

    if self.attachments or self.ical:
        msg = self._multipart()
    else:
        msg = self._plaintext()

    if self.smime:
        if 'from_key' in self.smime and 'from_crt' in self.smime:
            msg = sign(msg, self.smime['from_key'], self.smime['from_crt'])

        if 'to_crts' in self.smime:
            msg = encrypt(msg, self.smime['to_crts'])

    for name, value in self.headers.items():
        msg[name] = value

    if self.dkim:

        for key, value in self.dkim.items():
            self.dkim[key] = value.encode()

        dkim_header = dkim_sign(msg.as_bytes(), **self.dkim).decode()
        name, value = dkim_header.split(':', 1)
        msg[name] = value

    return msg.as_string()

attach(self, file_path)

Add file to message attachments.

Adds a given path to the set of files which are appended to the generated message when the method as_string is called.

Parameters:

Name Type Description Default
file_path str

relative or absolute path to the file.

required
Source code in spool/message.py
168
169
170
171
172
173
174
175
176
177
178
def attach(self, file_path):
    """Add file to message attachments.

    Adds a given path to the set of files which are appended to
    the generated message when the method `as_string` is called.

    Args:
        file_path (str): relative or absolute path to the file.
    """

    self.attachments.append(file_path)

MessageError

Base class for message related errors.

parse_addrs(addrs)

Parses a comma separated string to list of email addresses.

Wrapper arround pythons email.utils.parseaddr function to parse a comma separated list of email addresses.

Parameters:

Name Type Description Default
addrs

Comma separated string or list of email addresses to parse

required

Returns:

Type Description
list

A list of tuples consiting of realname and email address parts

Examples:

1
2
3
4
>>> parse_addrs('john doe <john@example.com>, jane.doe@example.com')
[('john doe', 'john@example.com'), ('', 'jane.doe@example.com')]
>>> parse_addrs(', john doe <john.doe@example.com>')
[('john doe', 'john.doe@example.com')]
Source code in spool/message.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def parse_addrs(addrs):
    """Parses a comma separated string to list of email addresses.

    Wrapper arround pythons `email.utils.parseaddr` function to parse a
    comma separated list of email addresses.

    Args:
        addrs: Comma separated string or list of email addresses to parse

    Returns:
        list: A list of tuples consiting of *realname* and *email address*
            parts

    Examples:
        >>> parse_addrs('john doe <john@example.com>, jane.doe@example.com')
        [('john doe', 'john@example.com'), ('', 'jane.doe@example.com')]
        >>> parse_addrs(', john doe <john.doe@example.com>')
        [('john doe', 'john.doe@example.com')]
    """

    if isinstance(addrs, str):
        addrs = addrs.split(',')

    if isinstance(addrs, list):
        return [parseaddr(item) for item in addrs if item]

    return [parseaddr(addrs)]

parser

Config

Represents a single mail instance config.

load(config) staticmethod

Create a config object from a config file.

Source code in spool/parser.py
163
164
165
166
167
168
169
170
171
@staticmethod
def load(config):
    """Create a config object from a config file."""

    if not isinstance(config, dict):
        LOG.info('Parsing config file. [path=%s]', config)
        with open(config, 'r') as fh:
            config = yaml.safe_load(fh)
    return Config(config)

ConfigError

Base class for all parsing errors.

ValidationError

Validation Error.

to_list(string)

Returns a list of values from a comma separated string

Source code in spool/parser.py
12
13
14
15
16
17
18
def to_list(string):
    """Returns a list of values from a comma separated string"""

    if isinstance(string, list):
        return string

    return [item.strip() for item in string.split(',')]

smime

encode_cms(mime_part)

Encodes a cms structure

Source code in spool/smime.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def encode_cms(mime_part):
    """Encodes a cms structure"""

    mime_part['Content-Transfer-Encoding'] = 'base64'

    if mime_part.get_payload() is None:
        return mime_part

    cms = mime_part.get_payload().pem()

    match = PEM_RE.search(cms)
    if not match:
        raise ValueError('Failed to retrive cms')

    mime_part.set_payload(match.group(2))

    return mime_part

encrypt(message, certs, algorithm='des3')

Encrypt a given message.

Source code in spool/smime.py
85
86
87
88
89
90
91
92
93
94
95
def encrypt(message, certs, algorithm='des3'):
    """Encrypt a given message."""

    certs, cipher = parse_pem(certs), CipherType(algorithm)

    cms = EnvelopedData.create(certs, message.as_bytes(), cipher, flags=0)

    encrypted = MIMEApplication(cms, 'pkcs7-mime', encode_cms,
                                smime_type='enveloped-data', name='smime.p7m')

    return encrypted

parse_pem(certstack)

Extract PEM strings from certstack.

Source code in spool/smime.py
23
24
25
26
27
28
29
30
31
def parse_pem(certstack):
    """Extract PEM strings from *certstack*."""

    certs = [
        X509(match.group(0)) for match
        in PEM_RE.finditer(certstack)
    ]

    return certs

sign(message, key, cert, detached=True)

Sign a a given message.

Source code in spool/smime.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def sign(message, key, cert, detached=True):
    """Sign a a given message."""

    # FIXME
    if not detached:
        raise NotImplementedError()

    signed = MIMEMultipart(
        'signed', micalg='sha-256', protocol='application/pkcs7-signature')
    signed.preamble = SIGNED_PREAMBLE

    signed.attach(message)

    cann = message.as_bytes().replace(b'\n', b'\r\n')

    key, certstack = PKey(privkey=key.encode()), parse_pem(cert)

    flags = Flags.DETACHED+Flags.BINARY

    cms = SignedData.create(cann, certstack[-1], key, flags=flags,
                            certs=certstack[0:-1])

    signature = MIMEApplication(
        cms, 'pkcs7-signature', encode_cms, name='smime.p7s')
    signature.add_header(
        'Content-Disposition', 'attachment', filename='smime.p7s')

    signed.attach(signature)

    return signed