commit 60061049d8003ea6c3d8a2a1d534253df3dae51e Author: Antonio J. Delgado Date: Fri Aug 2 18:06:21 2024 +0300 Add initial files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d57177 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +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/ + +# Configuration files +*.conf +*.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15c8bf2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3 + +WORKDIR /usr/src/app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN pip install . +VOLUME config + +CMD [ "python", "/usr/local/bin/mastodon_email_bridge.py", "--config", "/config/config.conf" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f5b875 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# mastodon_email_bridge + +## Requirements + +## Installation + +### Linux + + ```bash +sudo python3 setup.py install +``` + +### Windows (from PowerShell) + + ```powershell +& $(where.exe python).split()[0] setup.py install +``` + +## Usage + + ```bash +mastodon_email_bridge.py [--debug-level|-d CRITICAL|ERROR|WARNING|INFO|DEBUG|NOTSET] # Other parameters +``` diff --git a/config/config.conf_sample b/config/config.conf_sample new file mode 100644 index 0000000..db2dea8 --- /dev/null +++ b/config/config.conf_sample @@ -0,0 +1,3 @@ +debug_level='DEBUG' +log_file='/config/mastodon_email_bridge.log' +# list_of_parameters=['foo', 'bar'] diff --git a/mastodon_email_bridge/__init__.py b/mastodon_email_bridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mastodon_email_bridge/mastodon_email_bridge.py b/mastodon_email_bridge/mastodon_email_bridge.py new file mode 100755 index 0000000..95e88a5 --- /dev/null +++ b/mastodon_email_bridge/mastodon_email_bridge.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- +# +# This script is licensed under GNU GPL version 2.0 or above +# (c) 2024 Antonio J. Delgado +"""Redirect the Mastodon home timeline to email""" + +import json +import time +import sys +import os +import logging +from logging.handlers import SysLogHandler +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +import sqlite3 +import click +import click_config_file +import requests +from jinja2 import Environment, PackageLoader, select_autoescape + +class MastodonEmailBridge: + '''CLass to redirect the Mastodon home timeline to email''' + + def __init__(self, **kwargs): + self.config = kwargs + if 'log_file' not in kwargs or kwargs['log_file'] is None: + self.config['log_file'] = os.path.join( + os.environ.get( + 'HOME', + os.environ.get( + 'USERPROFILE', + os.getcwd() + ) + ), + 'log', + 'mastodon_email_bridge.log' + ) + if 'sent_items_file' not in kwargs or kwargs['sent_items_file'] is None: + self.config['sent_items_file'] = os.path.join( + os.environ.get( + 'HOME', + os.environ.get( + 'USERPROFILE', + os.getcwd() + ) + ), + '.mastodon_email_bridge_sent_items.db' + ) + self._init_log() + self._get_sent_posts() + self.j2env = Environment( + loader=PackageLoader("mastodon_email_bridge"), + autoescape=select_autoescape() + ) + self.session = requests.Session() + self.session.headers.update({'Authorization': f"Bearer {self.config['token']}"}) + count=1 + self._log.debug( + "Getting URL 'https://%s/api/v1/timelines/home?limit=1'", + self.config['server'] + ) + next_url = f"https://{self.config['server']}/api/v1/timelines/home?limit=1" + next_url = self.process_url(next_url) + while next_url != "" and (count < self.config['limit'] or self.config['limit'] == 0): + count = count + 1 + self._log.debug("Getting URL '%s'", next_url) + next_url = self.process_url(next_url) + + def _get_sent_posts(self): + self.sent_items = [] + self.sqlite = sqlite3.connect(self.config['sent_items_file']) + self.sqlite.row_factory = sqlite3.Row + cur = self.sqlite.cursor() + cur.execute("CREATE TABLE IF NOT EXISTS sent_items(id, date)") + self.sqlite.commit() + res = cur.execute("SELECT id FROM sent_items") + rows = res.fetchall() + for row in rows: + self.sent_items.append(row[0]) + return True + + def process_url(self, url): + '''Process a home endpoint URL''' + next_url = '' + result = self.session.get(url) + data = result.json()[0] + if data['id'] not in self.sent_items: + self.send_mail(data) + if 'Link' in result.headers: + for link in result.headers['Link'].split(', '): + slink = link.split('; ') + if slink[1] == 'rel="next"': + next_url = slink[0].replace('<', '').replace('>', '') + return next_url + + def send_mail(self, data): + '''Send an email with the post composed''' + msg = MIMEMultipart('alternative') + msg['Subject'] = f"FediPost from {data['account']['display_name']} ({data['account']['username']})" + msg['From'] = self.config['sender'] + msg['To'] = self.config['recipient'] + html_template = self.j2env.get_template("new_post.html.j2") + html_source = html_template.render( + data=data, + json_raw=json.dumps(data, indent=2) + ) + txt_template = self.j2env.get_template("new_post.txt.j2") + txt_source = txt_template.render( + data=data, + json_raw=json.dumps(data, indent=2) + ) + sent_folder = os.path.join( + os.environ.get( + 'HOME', + os.environ.get( + 'USERPROFILE', + os.getcwd() + ) + ), + '.mastodon_email_bridge_sent_items' + ) + if not os.path.exists(sent_folder): + os.mkdir(sent_folder) + sent_file = os.path.join(sent_folder, data['id'] + '.html') + with open(sent_file, 'a', encoding="utf-8") as sent_item_file: + sent_item_file.write(html_source) + part1 = MIMEText(txt_source, 'plain') + part2 = MIMEText(html_source, 'html') + msg.attach(part1) + msg.attach(part2) + conn=smtplib.SMTP_SSL(self.config['mail_server'],self.config['mail_server_port']) + if self.config['mail_user'] is not None: + conn.login(self.config['mail_user'], self.config['mail_pass']) + self._log.debug("Sending email...") + conn.sendmail(self.config['sender'], self.config['recipient'], msg.as_string()) + conn.quit() + self._log.debug("Adding entry to database...") + cur = self.sqlite.cursor() + cur.execute(f"INSERT INTO sent_items (id, date) VALUES ({data['id']}, {time.time()})") + self.sqlite.commit() + self.sent_items.append(data['id']) + return True + + def _init_log(self): + ''' Initialize log object ''' + self._log = logging.getLogger("mastodon_email_bridge") + self._log.setLevel(logging.DEBUG) + + sysloghandler = SysLogHandler() + sysloghandler.setLevel(logging.DEBUG) + self._log.addHandler(sysloghandler) + + streamhandler = logging.StreamHandler(sys.stdout) + streamhandler.setLevel( + logging.getLevelName(self.config.get("debug_level", 'INFO')) + ) + self._log.addHandler(streamhandler) + + if 'log_file' in self.config: + log_file = self.config['log_file'] + else: + home_folder = os.environ.get( + 'HOME', os.environ.get('USERPROFILE', '') + ) + log_folder = os.path.join(home_folder, "log") + log_file = os.path.join(log_folder, "mastodon_email_bridge.log") + + if not os.path.exists(os.path.dirname(log_file)): + os.mkdir(os.path.dirname(log_file)) + + filehandler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=102400000 + ) + # create formatter + formatter = logging.Formatter( + '%(asctime)s %(name)-12s %(levelname)-8s %(message)s' + ) + filehandler.setFormatter(formatter) + filehandler.setLevel(logging.DEBUG) + self._log.addHandler(filehandler) + return True + + +@click.command() +@click.option( + "--debug-level", + "-d", + default="INFO", + type=click.Choice( + ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"], + case_sensitive=False, + ), + help='Set the debug level for the standard output.' +) +@click.option('--token', '-t', required=True, help='Mastodon token with read access.') +@click.option( + '--server', + '-s', + default='eu.mastodon.green', + help='Mastodon server full qualified name.' +) +@click.option('--limit', '-L', default=0, help='Mastodon token with read access.') +@click.option('--recipient', '-r', required=True, help='Recipient email to get the posts.') +@click.option( + '--sender', + '-S', + default='mastodon_email_bridge@example.org', + help='Sender email thant send the posts.' +) +@click.option( + '--sent-items-file', + '-f', + default=None, + help='File to store the IDs of post already sent by email.' +) +@click.option('--mail-server', '-m', default='localhost', help='SMTP Mail server to send emails.') +@click.option( + '--mail-user', + '-u', + default=None, + help='Username for SMTP Mail server to send emails.' +) +@click.option( + '--mail-pass', + '-P', + default=None, + help='User password for SMTP Mail server to send emails.' +) +@click.option( + '--mail-server-port', + '-p', + default=465, + help='SMTP Mail server port to send emails.' +) +@click.option('--log-file', '-l', help="File to store all debug messages.") +# @click.option("--dummy","-n", is_flag=True, +# help="Don't do anything, just show what would be done.") +@click_config_file.configuration_option() +def __main__(**kwargs): + return MastodonEmailBridge(**kwargs) + + +if __name__ == "__main__": + __main__() diff --git a/mastodon_email_bridge/templates/new_post.html.j2 b/mastodon_email_bridge/templates/new_post.html.j2 new file mode 100644 index 0000000..c21dc60 --- /dev/null +++ b/mastodon_email_bridge/templates/new_post.html.j2 @@ -0,0 +1,108 @@ + + + + + + + + +
+ {{ data['account']['display_name'] }} avatar image +
+ {{ data['account']['display_name'] }} ({{ data['account']['username'] }}) +
+ +
+ {{ data['created_at'] }} +
+ +
+ +
+ {{ data['spoiler'] }} +
+
+ +
+ {{ data['content'] }} + + {% if data['media_attachments'] %} + {% for media in data['media_attachments'] %} +
+ {% if media['type'] == 'image' %} + {{ media['description'] }} + {% elif media['type'] == 'video' %} + + {% elif media['type'] == 'audio' %} + + Download audio + {% endif %} +
+ {% endfor %} + {% endif %} + {% if data['reblog'] %} + +
+ +
+ {{ data[ +
+ {{ data['reblog']['account']['display_name'] }} ({{ data['reblog']['account']['username'] }}) +
+ +
+ {{ data['reblog']['created_at'] }} +
+ +
+ +
+ {{ data['reblog']['spoiler'] }} +
+
+ +
+ {{ data['reblog']['content'] }} + + {% if data['reblog']['media_attachments'] %} + {% for media in data['reblog']['media_attachments'] %} + {% if media['type'] == 'image' %} + {{ media['description'] }} + {% elif media['type'] == 'video' %} + + {% elif media['type'] == 'audio' %} + + Download audio + {% endif %} + {% endfor %} + {% endif %} +
+
+
+ {% endif %} +
+
+ +
+ Post original page +
+ {# {{ data['card'] }} #} +
+
+
+
+ +
+ Raw JSON data: +
{{ json_raw }}
+
+ diff --git a/mastodon_email_bridge/templates/new_post.txt.j2 b/mastodon_email_bridge/templates/new_post.txt.j2 new file mode 100644 index 0000000..67dcedd --- /dev/null +++ b/mastodon_email_bridge/templates/new_post.txt.j2 @@ -0,0 +1,39 @@ +{{ data['account']['display_name'] }} ({{ data['account']['username'] }}) +{{ data['created_at'] }} + +{{ data['spoiler'] }} +{{ data['content'] }} +{% if data['media_attachments'] %} + {% for media in data['media_attachments'] %} + {% if media['type'] == 'image' %} + {{ media['description'] }} => {{ media['preview_url'] }} + {% elif media['type'] == 'video' %} + {{ media['description'] }} => {{ media['url'] }} + {% elif media['type'] == 'audio' %} + {{ media['description'] }} => {{ media['url'] }} + {% endif %} + {% endfor %} +{% endif %} +{% if data['reblog'] %} +Reblogged from {{ data['reblog']['account']['display_name'] }} ({{ data['reblog']['account']['username'] }}) +{{ data['reblog']['created_at'] }} + +{{ data['reblog']['spoiler'] }} +{{ data['reblog']['content'] }} +{% if data['reblog']['media_attachments'] %} + {% for media in data['reblog']['media_attachments'] %} + {% if media['type'] == 'image' %} + {{ media['description'] }} => {{ media['preview_url'] }} + {% elif media['type'] == 'video' %} + {{ media['description'] }} => {{ media['url'] }} + {% elif media['type'] == 'audio' %} + {{ media['description'] }} => {{ media['url'] }} + {% endif %} + {% endfor %} +{% endif %} + +{% endif %} +Original page: {{ data['url'] }} + +Raw JSON data: +{{ json_raw }} diff --git a/podman_build.sh b/podman_build.sh new file mode 100755 index 0000000..ed4b2d1 --- /dev/null +++ b/podman_build.sh @@ -0,0 +1,2 @@ +#!/bin/sh +podman build -t mastodon_email_bridge . diff --git a/podman_run.sh b/podman_run.sh new file mode 100755 index 0000000..75ddc47 --- /dev/null +++ b/podman_run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +podman run --mount type=bind,src=config/,target=/config -t --rm --name mastodon_email_bridge mastodon_email_bridge diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..96168e1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project.urls] +Homepage = "" + +[project] +name = "mastodon_email_bridge" +version = "0.0.1" +description = "Redirect the home timeline to email" +readme = "README.md" +authors = [{ name = "Antonio J. Delgado", email = "ad@susurrando.com" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: GPLv3 License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +#keywords = ["vCard", "contacts", "duplicates"] +dependencies = [ + "click", + "click_config_file", +] +requires-python = ">=3" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66bf966 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +click +click_config_file \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d46dfcb --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[metadata] +name = mastodon_email_bridge +version = 0.0.1 + +[options] +packages = mastodon_email_bridge +install_requires = + requests + importlib; python_version == "3.10" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..83ecd51 --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Setup script""" + +import configparser +import setuptools + +config = configparser.ConfigParser() +config.read('setup.cfg') + +setuptools.setup( + scripts=['mastodon_email_bridge/mastodon_email_bridge.py'], + author="Antonio J. Delgado", + version=config['metadata']['version'], + name=config['metadata']['name'], + author_email="ad@susurrando.com", + url="", + description="Redirect the home timeline to email", + long_description="README.md", + long_description_content_type="text/markdown", + license="GPLv3", + # keywords=["my", "script", "does", "things"] +)