Add initial files

This commit is contained in:
Antonio J. Delgado 2024-08-02 18:06:21 +03:00
commit 60061049d8
15 changed files with 635 additions and 0 deletions

142
.gitignore vendored Normal file
View file

@ -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

11
Dockerfile Normal file
View file

@ -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" ]

0
LICENSE Normal file
View file

23
README.md Normal file
View file

@ -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
```

View file

@ -0,0 +1,3 @@
debug_level='DEBUG'
log_file='/config/mastodon_email_bridge.log'
# list_of_parameters=['foo', 'bar']

View file

View file

@ -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__()

View file

@ -0,0 +1,108 @@
<!doctype html>
<html lang="{{ data['language'] }}">
<head>
<meta charset="utf-8"/>
</head>
<style>
p { margin: 1%; }
div { margin: 2%; }
</style>
<BODY>
<!-- account bloc -->
<DIV>
<IMG ALT="{{ data['account']['display_name'] }} avatar image" SRC="{{ data['account']['avatar_static'] }}" STYLE="width:46px;height:46px;margin:1%;">
</BR>
<B>{{ data['account']['display_name'] }} ({{ data['account']['username'] }})</B>
</DIV>
<!-- creation_date -->
<DIV STYLE='font-size: 0.75em;'>
{{ data['created_at'] }}
</DIV>
<!-- content block -->
<DIV STYLE='font-size: 2.5em;'>
<!-- spoiler -->
<DIV CLASS='item-spoiler'>
{{ data['spoiler'] }}
</DIV>
</BR>
<!-- item-content -->
<DIV CLASS='item-content' STYLE="margin:5%;">
{{ data['content'] }}
<!-- media -->
{% if data['media_attachments'] %}
{% for media in data['media_attachments'] %}
<DIV STYLE="margin:2%;">
{% if media['type'] == 'image' %}
<IMG SRC="{{ media['preview_url'] }}" ALT="{{ media['description'] }}">
{% elif media['type'] == 'video' %}
<video controls width="100%">
<source src="{{ media['url'] }}" type="video/webm" />
<A HREF="{{ media['url'] }}">Download video</A>
</video>
{% elif media['type'] == 'audio' %}
<audio controls src="{{ media['url'] }}"></audio>
<A HREF="{{ media['url'] }}">Download audio</A>
{% endif %}
</DIV>
{% endfor %}
{% endif %}
{% if data['reblog'] %}
<!-- reblog -->
<DIV STYLE="margin:5%;">
<!-- reblog-account -->
<DIV>
<IMG ALT='{{ data['reblog']['account']['display_name'] }} avatar image' SRC='{{ data['reblog']['account']['avatar_static'] }}' STYLE='width:46px;height:46px;margin:1%'>
</BR>
<B>{{ data['reblog']['account']['display_name'] }} ({{ data['reblog']['account']['username'] }})</B>
</DIV>
<!-- reblog_creation_date -->
<DIV STYLE='font-size: 0.75em;'>
{{ data['reblog']['created_at'] }}
</DIV>
<!-- reblog_content_bloc -->
<DIV STYLE='font-size: 2.5em;'>
<!-- reblog_spoiler -->
<DIV CLASS='reblog-spoiler'>
{{ data['reblog']['spoiler'] }}
</DIV>
</BR>
<!-- reblog_content -->
<DIV CLASS='reblog-content'>
{{ data['reblog']['content'] }}
<!-- media -->
{% if data['reblog']['media_attachments'] %}
{% for media in data['reblog']['media_attachments'] %}
{% if media['type'] == 'image' %}
<IMG SRC="{{ media['preview_url'] }}" ALT="{{ media['description'] }}">
{% elif media['type'] == 'video' %}
<video controls width="100%">
<source src="{{ media['url'] }}" type="video/webm" />
<A HREF="{{ media['url'] }}">Download video</A>
</video>
{% elif media['type'] == 'audio' %}
<audio controls src="{{ media['url'] }}"></audio>
<A HREF="{{ media['url'] }}">Download audio</A>
{% endif %}
{% endfor %}
{% endif %}
</DIV>
</DIV>
</DIV>
{% endif %}
</DIV>
</DIV>
<!-- URL -->
<DIV>
<A TARGET="_blank" HREF="{{ data['url'] }}">Post original page</A>
</DIV>
{# <!-- card -->{{ data['card'] }} #}
</BR>
</BR>
</BR>
</BR>
<!-- Raw JSON data -->
<DIV>
Raw JSON data:
<PRE>{{ json_raw }}</PRE>
</DIV>
</BODY>

View file

@ -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 }}

2
podman_build.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
podman build -t mastodon_email_bridge .

2
podman_run.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
podman run --mount type=bind,src=config/,target=/config -t --rm --name mastodon_email_bridge mastodon_email_bridge

25
pyproject.toml Normal file
View file

@ -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"

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
click
click_config_file

9
setup.cfg Normal file
View file

@ -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"

23
setup.py Normal file
View file

@ -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"]
)