Skip to content

Instantly share code, notes, and snippets.

@haxwithaxe
Last active September 17, 2023 18:50
Show Gist options
  • Save haxwithaxe/369c5364e36862a8bb0f8ce60cdd0993 to your computer and use it in GitHub Desktop.
Save haxwithaxe/369c5364e36862a8bb0f8ce60cdd0993 to your computer and use it in GitHub Desktop.
A sendmail-like script without a mail daemon required.
[mail]
sender-email = haxmail <[email protected]>
subject = Default subject
to = [email protected]
smtp-username = [email protected]
smtp-password = <mailbot smtp password>
smtp-server = smtp.example.com
smtp-port = 587
#!/usr/bin/env python3
"""A sendmail-like script without a mail daemon required."""
import argparse
import configparser
import email
import enum
import os
import pathlib
import select
import smtplib
import ssl
import sys
class ConfigError(Exception):
"""A configuration error.
Raised if an error in configuration is found.
Arguments:
key (str): The config/argument name to be used in the message.
value_type (callable, optional): The type constructor of the config
value.
extend (str, optional): A message to append to the base message.
"""
def __init__(self, key, value_type=None, extend=None):
message = (f'Missing value for {key}. Use the `--{key}` argument '
f'or set `{key}` in the config file.')
if value_type:
message = (f'Argument `--{key}` or config `{key}` must be an '
f'instance of type {value_type}.')
if extend:
message = f'{message} {extend}'
super().__init__(message)
class Flag(enum.Enum): # pylint: disable=missing-class-docstring
USE_HEADER = 'use_header'
def _truethy(value):
return str(value).lower() not in ('false', 'off', 'no', 'none', 'null')
def set_mail_header(mail, key, header_key, value):
"""Set a header value in the `mail` object."""
if not value and not mail.get(header_key):
raise ConfigError(key, extend=f'Nothing is given for {header_key} '
'header and it is empty.')
if value is not Flag.USE_HEADER:
if mail.get(header_key):
mail.replace_header(header_key, value)
else:
mail.add_header(header_key, value)
def send_email(message, sender_email, to_address, subject, config):
"""Send the email.
All string arguments except for `message` can be `Flag.USE_HEADER` to force
the use of the headers in the message in `message` as the source of the
value.
Arguments:
message (str): The message or body of the message.
sender_email (str): The email address of the sender.
to_address (str): The email address of the recipient.
subject (str): The subject line of the message.
config (dict): The configuration values given in the config file or
the commandline arguments.
Raises:
ConfigError
"""
if config.get('only-body'):
msg = email.message.EmailMessage()
msg.set_content(message)
else:
msg = email.message_from_string(message)
if sender_email is Flag.USE_HEADER and not msg.get('From'):
raise ConfigError(
'sender-email',
extend='No From header in the message.'
)
if subject is Flag.USE_HEADER and not msg.get('Subject'):
raise ConfigError(
'subject',
extend='No Subject header in the message.'
)
if to_address is Flag.USE_HEADER and not (msg.get('To') or msg.get('Cc')):
raise ConfigError('to', extend='No To or Cc header in the message.')
# Set headers if values are given.
set_mail_header(msg, 'sender-email', 'From', sender_email)
set_mail_header(msg, 'subject', 'Subject', subject)
set_mail_header(msg, 'to', 'To', to_address)
# Grab the To header if using header for value
if to_address is Flag.USE_HEADER and msg.get('To') or msg.get('Cc'):
to_address = msg.get('To', msg.get('Cc'))
msg.set_charset('utf-8')
tls_context = ssl.create_default_context()
with smtplib.SMTP(config.get('smtp-server'),
config.get('smtp-port')) as server:
server.ehlo()
server.starttls(context=tls_context)
server.ehlo()
server.login(config.get('smtp-username'), config.get('smtp-password'))
server.sendmail(config.get('smtp-username'), to_address,
msg.as_bytes())
def _set_config(config, key, arg, value_type=str, default=None):
"""Configuration helper.
Arguments:
config (dict): The config store.
key (str): The config key.
arg: The command line argument value.
value_type (callable, optional): The value type or type constructor.
default (optional): The default value of the config.
"""
if arg is not None:
config[key] = arg
return
if not config.get(key) and default is None:
raise ConfigError(key)
value = config.get(key, default)
if isinstance(value, Flag):
config[key] = value
return
if value_type is bool and not _truethy(value):
value = False
try:
config[key] = value_type(value)
except TypeError as err:
raise ConfigError(key, value_type=value_type) from err
def _set_configs(args):
"""Configuration helper.
Arguments:
args (argparse.Namespace): The commandline arguments.
"""
possible_config_files = (
args.config.name if args.config else None,
os.path.join(os.environ['HOME'], '.config/haxmail.ini'),
'/usr/local/etc/haxmail.ini',
'/etc/haxmail.ini'
)
config = {}
for possible_config in possible_config_files:
if possible_config:
path = pathlib.Path(possible_config)
if not path.is_file():
continue
try:
config_text = path.read_text()
except PermissionError:
print('Permission to read', possible_config, 'was denied.',
file=sys.stderr)
continue
print(possible_config)
conf_parser = configparser.ConfigParser()
conf_parser.read_string(config_text)
if 'mail' not in conf_parser.sections():
print(possible_config, 'is not a valid config file',
file=sys.stderr)
continue
# Make the config a regular dict so it can contain ints
config = dict(conf_parser['mail'].items())
break
else:
config = {}
use_headers = None
if _truethy(config.get('use-headers')) or args.use_headers:
use_headers = Flag.USE_HEADER
_set_config(config, 'sender-email', args.sender_email, default=use_headers)
_set_config(config, 'subject', args.subject, default=use_headers)
_set_config(config, 'to', args.to, default=use_headers)
_set_config(config, 'smtp-username', args.smtp_username)
_set_config(config, 'smtp-password', args.smtp_password)
_set_config(config, 'smtp-server', args.smtp_server)
_set_config(config, 'smtp-port', args.smtp_port, value_type=int,
default=587)
_set_config(config, 'only-body', args.only_body, value_type=bool,
default=False)
return config
def main(): # pylint: disable=missing-function-docstring
parser = argparse.ArgumentParser()
parser.add_argument('-f', '--sender-email', default=None, help='The From '
'address')
parser.add_argument('--subject', default=None)
parser.add_argument('--to', default=None)
# Using -t to conform to sendmail interface
parser.add_argument(
'-t',
'--use-headers',
action='store_true',
default=False,
help='Use the headers in the message for `To:`, `From:`, and '
'`Subject:`. This emulates exim sendmail.'
)
parser.add_argument('--smtp-username', default=None)
parser.add_argument('--smtp-password', default=None)
parser.add_argument('--smtp-server', default=None)
parser.add_argument('--smtp-port', type=int, default=None)
parser.add_argument('--only-body', action='store_true', default=None,
help='If given it is assumed that the message has no '
'headers.')
# Using -C to conform to sendmail interface
parser.add_argument(
'-C',
'--config',
type=argparse.FileType('r'),
default=None,
help='The haxmail.ini file. If no file is given this searches the '
'following directories in the following order: $HOME/.config, '
'/usr/local/etc, /etc'
)
args = parser.parse_args()
if args.use_headers and args.only_body:
print('Either -t or --only-body can be given, but not both.',
file=sys.stderr)
sys.exit(1)
config = _set_configs(args)
if select.select([sys.stdin], [], [], 0)[0]:
send_email(
sys.stdin.read(),
sender_email=config.get('sender-email'),
to_address=config.get('to'),
subject=config.get('subject'),
config=config
)
if __name__ == '__main__':
try:
main()
except Exception as error: # pylint: disable=broad-except
print(error, file=sys.stderr)
sys.exit(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment