diff --git a/README.md b/README.md index 111712f..5d25b5b 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,13 @@ it You can also update this README by giving a brief description of your contrib ### Utils -Tools for sysadmins. +Tools for sysadmins : * [datasources_utils](utils/datasources_utils/README.md): view and test list datasources and custom attributes datasources * [sympatoldap](utils/sympatoldap/README.md): creates LDAP entries for every list (and its aliases) whose status is open on the LDAP server * [splitting_daemons_logs](utils/splitting_daemons_logs/README.md): split Sympa processus logs into separated files * [sqlite2mysql](utils/sqlite2mysql/README.md): attempt to convert sqlite to mysql + +Tools for mailing list owners : + +* [sympa-pymanage](utils/sympa-user-cli-pymanage/README.md): a basic python tool which sends [email commands](https://lists.sympa.community/help/commands.html) to the sympa server (eg: in order to update mailing lists from a program pipeline). diff --git a/utils/sympa-pymanage/.gitignore b/utils/sympa-pymanage/.gitignore new file mode 100644 index 0000000..cd788ee --- /dev/null +++ b/utils/sympa-pymanage/.gitignore @@ -0,0 +1,178 @@ +outputs/*.log +config/*.conf +poetry.lock + +# from https://github.com/github/gitignore/blob/main/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +*venv*/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/utils/sympa-pymanage/README.md b/utils/sympa-pymanage/README.md new file mode 100644 index 0000000..9354808 --- /dev/null +++ b/utils/sympa-pymanage/README.md @@ -0,0 +1,65 @@ +# Sympa Manage + +This is a basic python client program to send emails as a mailing list admin to the sympa server. + +You can see a list of commands available [here](https://lists.sympa.community/help/commands.html). + +It is not using [sympa SOAP API](https://www.sympa.community/manual/customize/soap-api.html), which, unfortunately, does not seem to be very actively used nowadays. + +Use it to ADD, DEL user or send STATS or REVIEW commands. + +## Requirements + +[`Typer`](https://typer.tiangolo.com/) python library. + + +## Configuration + +You need either a working MTA (eg. [_basic SMTP configuration working with Postfix_](http://www.postfix.org/BASIC_CONFIGURATION_README.html)), or a user/password configuration from an email provider. + +Whatever the solution you use, the email sender must have admin permissions on the admin list. You can check logs in the sympa server to see what mail address is used. + + +Every configuration must be set in `config/sympa.conf`. + +## Installation + +Clone that directory, and install the requirements in a virtualenv : + +```bash +python3 -m venv sympa_venv +source sympa_venv/bin/activate +python3 -m pip install typer +``` + + +## Basic usage + +Help : + +```bash +#source sympa_venv/bin/activate +python3 sympa_manage.py --help +``` + +Review : + +```bash +python3 sympa_manage.py -m my-list@lists.sympa.community +# same as the following (REVIEW is the default): +python3 sympa_manage.py REVIEW -m my-list@lists.sympa.community +``` + +Add : + +```bash +python3 sympa_manage.py "QUIET ADD" -m my-list@lists.sympa.community -u "john.doe@domain.tld John Doe" +# you repeat -u option as much as you want +python3 sympa_manage.py "QUIET ADD" -m my-list@lists.sympa.community -u "john.doe@domain.tld John Doe" -u jane.doe@domain.tld +``` + +Delete : + +```bash +python3 sympa_manage.py DEL -m my-list@lists.sympa.community -u john.doe@domain.tld +``` \ No newline at end of file diff --git a/utils/sympa-pymanage/TODO b/utils/sympa-pymanage/TODO new file mode 100644 index 0000000..a8c7792 --- /dev/null +++ b/utils/sympa-pymanage/TODO @@ -0,0 +1,2 @@ +- key message to use (for commands CONFIRM, REJECT, DISTRIBUTE) +- set of sub-actions \ No newline at end of file diff --git a/utils/sympa-pymanage/config/sympa.conf.sample b/utils/sympa-pymanage/config/sympa.conf.sample new file mode 100644 index 0000000..3717b7f --- /dev/null +++ b/utils/sympa-pymanage/config/sympa.conf.sample @@ -0,0 +1,20 @@ +[general] +# sympa email server address +sympa_mail=sympa@mailinglist.org +# either to receive or not an aknowledgement +confirmation=yes +confirmation_recipient=me@tld.com + +[send_method] +# either "local" or "custom" +# if local, use the local machine SMTP method +# if "custom", you must fullfill the next values. +email_send_method=local + +# following is needed only if you do not have a local working MTA... +smtp_remote_server="" +smtp_remote_port=465 +smtp_sender="" +smtp_receiver=", " +smtp_password="" + diff --git a/utils/sympa-pymanage/outputs/.gitkeep b/utils/sympa-pymanage/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utils/sympa-pymanage/pyproject.toml b/utils/sympa-pymanage/pyproject.toml new file mode 100644 index 0000000..723846e --- /dev/null +++ b/utils/sympa-pymanage/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "sympa-pymanage" +version = "0.1.0" +description = "A project to manage python list from terminal as a mailing list owner" +authors = [ + {name = "Rémy Dernat",email = "remy.dernat@umontpellier.fr"} +] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "typer (>=0.15.1,<0.16.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/utils/sympa-pymanage/sympa_manage.py b/utils/sympa-pymanage/sympa_manage.py new file mode 100644 index 0000000..01a6686 --- /dev/null +++ b/utils/sympa-pymanage/sympa_manage.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import typer +from typing import Optional +from typing import List +from typing_extensions import Annotated + +import utils.logger as l +from utils import bcolors as bc +from utils import parse_config as cfg +from utils import validate_options as v +from utils import email_format as ef + +import utils.email as ue + + +def text_info(action, mailinglist, usermail): + """ + Just a basic info displayed + """ + if usermail: + for um in usermail: + print(u"\N{check mark} Ok, this program will try your action '{}', on mailing list '{}' for user '{}'".\ + format(action, mailinglist, um)) + else: + print(u"\N{check mark} Ok, this program will try your action '{}', on mailing list '{}'".\ + format(action, mailinglist)) + print("Default action is a basic REVIEW command.") + + +def main(action: Annotated[str, typer.Argument(help = \ + "The sympa command to use (eg: QUIET ADD)")] = "REVIEW", \ + mailinglist: Annotated[str, typer.Option("--mailinglist", "-m", help = \ + "The mailing list where the action is performed")] = None, + usermail: Annotated[Optional[List[str]], typer.Option("--usermail", "-u")] \ + = None, msgkey: Annotated[str, typer.Option("--key", "-k", help = \ + "message key index")] = None): + """ + sympa_pymanage program + Parse options and do mail actions + """ + + config = cfg.parse_config() + from_addr = None + email_to = None + confirmation_recipient_to = None + + #CUSTOM = False + + list_of_sympa_commands = [ \ + "HELP", "LISTS", "WHICH", "CONFIRM", "QUIT", \ + "INFO", "REVIEW", "SUBSCRIBE", "INVITE", "UNSUBSCRIBE", "SET", \ + "INDEX", "GET", "LAST", "ADD", "QUIET ADD", "DEL", "QUIET DEL", \ + "STATS", "REMIND", "DISTRIBUTE", "REJECT", "MODINDEX" ] + sympa_set_subcommands = ["NOMAIL", "DIGEST", "DIGESTPLAIN", \ + "SUMMARY", "NOTICE", "MAIL", "CONCEAL", "NOCONCEAL"] + + + action = action.upper() + + if action not in list_of_sympa_commands: + typer.secho("Action command does dot seem to be a valid sympa command !\n\n\ + Please check https://lists.sympa.community/help/commands.html\n", \ + fg=typer.colors.RED) + raise typer.Exit() + + if mailinglist is None: + if action not in ["HELP", "LISTS", "WHICH", "CONFIRM"]: + typer.secho("mailinglist name is required for your command !\n\n\ + Please check https://lists.sympa.community/help/commands.html\n", \ + fg=typer.colors.RED) + raise typer.Exit() + mailinglist = "" + + sympa_addr = cfg.try_read_val(config, 'sympa_mail', 'general') + if not sympa_addr: + typer.secho("Sympa mail server address is mandatory. See `config/sympa.conf`", \ + fg=typer.colors.RED) + raise typer.Exit() + + confirmation = cfg.try_read_val(config, 'confirmation', 'general') + confirm_vals = ['yes', 'Yes', 'YES', 'true', 'True', 'TRUE', 'ok', 'OK', 'Ok'] + if confirmation in confirm_vals: + confirmation_recipient = cfg.try_read_val(config, 'confirmation_recipient', 'general') + if confirmation_recipient: + confirmation_recipient_to = confirmation_recipient.split(",") + confirmation_recipient_to = v.check_emails(confirmation_recipient_to) + + email_send_method = cfg.try_read_val(config, 'email_send_method', 'send_method') + if email_send_method in ["yes", "default", "custom"]: + #CUSTOM = True + email_to = cfg.try_read_val(config, 'smtp_receiver', 'send_method') + if email_to: + email_to = email_to.split(",") + email_to = v.check_emails(email_to) + alt_from = cfg.try_read_val(config, 'smtp_sender', 'send_method') + if alt_from: + from_addr = v.check_emails(alt_from) + else: + alt_from = cfg.try_read_val(config, 'receiver', 'general') + if alt_from: + from_addr = v.check_emails(alt_from) + else: + from_addr = "local" + + text_info(action, mailinglist, usermail) + ef.send_email(sympa_addr, action, usermail, mailinglist, \ + confirmation_recipient_to, from_addr, email_to, email_send_method) + + +if __name__ == "__main__": + typer.run(main) diff --git a/utils/sympa-pymanage/utils/bcolors.py b/utils/sympa-pymanage/utils/bcolors.py new file mode 100644 index 0000000..0eea790 --- /dev/null +++ b/utils/sympa-pymanage/utils/bcolors.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# from blender +class bcolors: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + + def disable(self): + self.HEADER = '' + self.OKBLUE = '' + self.OKGREEN = '' + self.WARNING = '' + self.FAIL = '' + self.ENDC = '' diff --git a/utils/sympa-pymanage/utils/email.py b/utils/sympa-pymanage/utils/email.py new file mode 100644 index 0000000..1c6a0a6 --- /dev/null +++ b/utils/sympa-pymanage/utils/email.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import smtplib +import ssl +from email.mime.text import MIMEText +import socket +import getpass +from utils import parse_config as cfg +from utils import validate_options as v +import utils.logger as l + + +def send(to, subject, content, signature, email_send_method="local", \ + smtp_server=None, smtp_port=465, smtp_sender=None, smtp_password=None): + + """Send email to manage sympa list + + :param to: email addresses to send to; should be the email of sympa mail server + :param subject: the subject + :param msg: full process information in json + :param email_send_method: "local" or "custom" (see config file) + :param smtp_server smtp server to use + :param smtp_port server port to use + :param smtp_password your mail password + :param smtp_sender "From: " field + """ + + config = cfg.parse_config() + + if not email_send_method: + email_send_method = cfg.try_read_val(config, 'email_send_method', 'send_method') + #if email_send_method == "no": + # l.logger.warn("Trying to send email but email_send_method is set to no") + + if not smtp_server: + smtp_server = cfg.try_read_val(config, 'smtp_remote_server', 'send_method') + if smtp_server: + smtp_server = v.strip_dquotes(smtp_server) + if not smtp_port: + smtp_port = cfg.try_read_int(config, 'smtp_remote_port', 'send_method') + if not smtp_password: + smtp_password = cfg.try_read_val(config, 'smtp_password', 'send_method') + if smtp_password: + smtp_password = v.strip_dquotes(smtp_password) + + host = socket.getfqdn() + user = getpass.getuser() + user_at_host = "{}@{}".format(user, host) + + if smtp_sender == "local": + smtp_sender = user_at_host + if not smtp_sender: + smtp_sender = cfg.try_read_val(config, 'smtp_sender', 'send_method') + if smtp_sender: + smtp_sender = v.strip_dquotes(smtp_sender) + else: + smtp_sender = "{}@{}".format(user, host) + + body = content + if signature: + body += '\n\n(Automatically sent by sympa_pymanage program)' + msg = MIMEText(body) + msg['Subject'] = "{}".format(subject) + msg['From'] = smtp_sender + if not isinstance(to, str): + msg['To'] = ', '.join(to) + else: + msg['To'] = to + + l.logger.info("Subject: {}".format(subject)) + l.logger.info("Body: {}".format(body)) + l.logger.info("Sender: {}".format(smtp_sender)) + l.logger.info("To: {}".format(msg['To'])) + l.logger.info("Sender: {}".format(smtp_sender)) + + if email_send_method == "local": + # Send the message via our own SMTP server. + with smtplib.SMTP('localhost') as server: + l.logger.info('Sending email to: {}'.format(msg['To'])) + server.send_message(msg) + else: + context = ssl.create_default_context() + with smtplib.SMTP_SSL(smtp_server, smtp_port, context=context) as server: + l.logger.info('Sending email to: {}'.format(msg['To'])) + server.login(smtp_sender, smtp_password) + server.send_message(msg) + \ No newline at end of file diff --git a/utils/sympa-pymanage/utils/email_format.py b/utils/sympa-pymanage/utils/email_format.py new file mode 100644 index 0000000..bdbf6b1 --- /dev/null +++ b/utils/sympa-pymanage/utils/email_format.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import utils.logger as l +import utils.email as ue + +def send_email(sympa_addr, action, usermail, mailinglist, \ + confirmation_recipient_to, from_addr, email_to, email_send_method): + """ + Mail formatting before sending it + """ + + if not usermail: + body = compute_string(action, "", mailinglist) + subject = body + else: + if len(usermail) > 1: + """ + Need to concatenate the body to send only one big mail. + """ + body = "" + for um in usermail: + body = body + "\r\n" + compute_string(action, um, mailinglist) + #subject = "{} on {}".format(action, mailinglist) + subject = "" + elif len(usermail) == 1: + body = compute_string(action, usermail[0], mailinglist) + subject = body + + body = body + "\r\n" + "QUIT" + + # signature is only added to the confirmation email + signature = False + # send mail to the server + ue.send(sympa_addr, subject, body, signature, email_send_method, None, None, from_addr, None) + # send optional confirmation to the receiver + if confirmation_recipient_to or email_to: + signature = True + body = "\r\n\ + This is a confirmation mail from sympa_pymanage.\r\n\ + Following command has been sent to the mail server:\r\n\ + {} \r\n".format(action) + if mailinglist: + body = body + " \r\n ...With mailinglist: {} \r\n".format(mailinglist) + if usermail: + if isinstance(usermail, list): + body = body + " \r\n ...With usermails: {} \r\n".format(", ".join(usermail)) + else: + body = body + " \r\n ...With usermail: {} \r\n".format(usermail) + + if confirmation_recipient_to: + ue.send(confirmation_recipient_to, subject, body, signature, \ + email_send_method, None, None, from_addr, None) + else: + ue.send(email_to, subject, body, signature, email_send_method, \ + None, None, from_addr, None) + + + +def compute_string(action, usermail, mailinglist): + """ + From https://lists.sympa.community/help/commands.html + It seems that every moderator or proprietary commands + look like this format: + CMD mailinglist + """ + if not usermail: + usermail="" + subject="{} {} {}".format(action, mailinglist, usermail) + return subject \ No newline at end of file diff --git a/utils/sympa-pymanage/utils/logger.py b/utils/sympa-pymanage/utils/logger.py new file mode 100644 index 0000000..779667c --- /dev/null +++ b/utils/sympa-pymanage/utils/logger.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import os +dir_path = os.path.dirname(os.path.realpath(__file__)) + + +#logging.basicConfig(level = logging.INFO) +# create a new logger instead of the default root logger +logger = logging.getLogger('sympa_pymanage') + +formatter = logging.Formatter('%(asctime)s | %(levelname)s | %(message)s') + +# create file handler which logs even warn messages +# info messages are displayed to stdout +logger.setLevel(logging.INFO) +fhw = logging.FileHandler(dir_path+'/../outputs/sympa_pymanage.log') +fhw.setLevel(logging.WARNING) +logger.addHandler(fhw) \ No newline at end of file diff --git a/utils/sympa-pymanage/utils/parse_config.py b/utils/sympa-pymanage/utils/parse_config.py new file mode 100644 index 0000000..29446ff --- /dev/null +++ b/utils/sympa-pymanage/utils/parse_config.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import configparser +from pathlib import Path +from os import access, R_OK +import utils.logger as l + +def file_readable(file): + """ + :Param path to a file + check if file is readable + """ + assert(access(file, R_OK)), f"File {file} is not readable" + return file + +def parse_config(): + """ + Parse config main function + :Return config + """ + config = configparser.ConfigParser() + paths = ["config/sympa.conf", "~/.config/sympa.conf", \ + "/etc/sympa_pymanage/sympa.conf"] + for p in paths: + path = Path(p) + if path.is_file(): + p = file_readable(p) + config.read(p) + break + return config + +def try_read_val(config, key, section): + """ + :Param configuration content, key, section + :Return value + """ + try: + val = config[section][key] + except Exception as e: + val = None + l.logger.error("Value {} in {} does not seem to exist: {}".format(key, section, e)) + return val + +def try_read_int(config, key, section): + """ + :Param configuration content, key, section + :Return value + """ + try: + val = config.getint(section, key) + except Exception as e: + val = None + l.logger.error("Value {} in {} does not seem to exist: {}".format(key, section, e)) + return val \ No newline at end of file diff --git a/utils/sympa-pymanage/utils/validate_options.py b/utils/sympa-pymanage/utils/validate_options.py new file mode 100644 index 0000000..7691a5a --- /dev/null +++ b/utils/sympa-pymanage/utils/validate_options.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import utils.logger as l +import re +import sys + +def strip_dquotes(s): + """ + If a string has single or double quotes around it, remove them. + Make sure the pair of quotes match. + If a matching pair of quotes is not found, + or there are less than 2 characters, return the string unchanged. + """ + if (len(s) >= 2 and s[0] == s[-1]) and s.startswith(("'", '"')): + return s[1:-1] + return s + +def check_emails(emails): + """ + Basic email check with regexp + param str or list of emails + return list of valid emails + """ + + regex = re.compile(r"([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]*])") + + if isinstance(emails, str): + email = emails.strip() + email = strip_dquotes(email) + if not re.fullmatch(regex, email): + sys.exit("Not a good mail format") + return email + + else: + sanitized_emails = [] + for email in emails: + email = email.strip() + email = strip_dquotes(email) + if not re.fullmatch(regex, email): + emails.remove(email) + else: + sanitized_emails.append(email) + return sanitized_emails \ No newline at end of file