Last active
September 17, 2023 18:50
-
-
Save haxwithaxe/369c5364e36862a8bb0f8ce60cdd0993 to your computer and use it in GitHub Desktop.
A sendmail-like script without a mail daemon required.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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