Compare commits
No commits in common. "master" and "0.2.0" have entirely different histories.
8 changed files with 266 additions and 628 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -129,5 +129,3 @@ dmypy.json
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
#VScode
|
|
||||||
.vscode/
|
|
||||||
|
|
18
Makefile
18
Makefile
|
@ -1,18 +0,0 @@
|
||||||
install:
|
|
||||||
mkdir -p ${HOME}/.local/bin
|
|
||||||
mkdir -p ${HOME}/.config/systemd/user
|
|
||||||
install -m 700 ./mms2mail ${HOME}/.local/bin/
|
|
||||||
install -m 755 ./mms2mail.service ${HOME}/.config/systemd/user/
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
configure:
|
|
||||||
systemctl --user enable mms2mail
|
|
||||||
|
|
||||||
start:
|
|
||||||
systemctl --user start mms2mail
|
|
||||||
|
|
||||||
deb-deps:
|
|
||||||
sudo apt install python3-pydbus python3-aiosmtpd
|
|
||||||
|
|
||||||
pypy-deps:
|
|
||||||
pip install --user -r requirements.txt
|
|
86
README.md
86
README.md
|
@ -1,53 +1,22 @@
|
||||||
# mms2mail
|
# mms2mail
|
||||||
|
|
||||||
Mail bridge for mmsd.
|
Convert MMSd MMS file to mbox.
|
||||||
|
|
||||||
mms2mail:
|
|
||||||
* convert incoming mms from mmsd to mail and store it in unix mbox.
|
|
||||||
* provide a smtp server converting mail to mms with mmsd.
|
|
||||||
|
|
||||||
By default:
|
|
||||||
* store mails in the current user mbox (```/var/mail/$USER```)
|
|
||||||
* in case the mbox is locked by another process the output could be found in ```$HOME/.mms/failsafembox```
|
|
||||||
* listen on localhost port 2525 for mail
|
|
||||||
|
|
||||||
## installation
|
## installation
|
||||||
|
|
||||||
### dependency
|
### dependency
|
||||||
|
|
||||||
- python3
|
- python3
|
||||||
- python3-aiosmtpd
|
- python3-watchdog (pip install watchdog)
|
||||||
- python3-pydbus
|
- python-messaging (pip install python-messaging)
|
||||||
|
- marrow.mailer (pip install marrow.mailer)
|
||||||
|
|
||||||
### setup
|
### setup
|
||||||
|
Install the dependency and mms2mail:
|
||||||
Install the dependency and mms2mail (on debian based distribution):
|
|
||||||
```
|
```
|
||||||
make deb-deps install
|
pip install --user watchdog # or sudo apt install python3-watchdog
|
||||||
```
|
pip install --user marrow-mailer
|
||||||
For other distribution:
|
pip install --user python-messaging
|
||||||
```
|
|
||||||
make pypy-deps install
|
|
||||||
```
|
|
||||||
|
|
||||||
To enable the daemon mode in systemd user :
|
|
||||||
```
|
|
||||||
make configure start
|
|
||||||
```
|
|
||||||
|
|
||||||
Depending on your distribution, you might have to add your account to the ```mail``` group to be able to lock and use the system mbox.
|
|
||||||
On Debian based distribution :
|
|
||||||
```
|
|
||||||
sudo addgroup $(whoami) mail
|
|
||||||
```
|
|
||||||
|
|
||||||
#### manual install
|
|
||||||
|
|
||||||
Install the dependency and mms2mail (on debian based distribution):
|
|
||||||
```
|
|
||||||
sudo apt-get install python3
|
|
||||||
sudo apt-get install python3-pydbus
|
|
||||||
sudo apt-get install python3-aiosmtpd
|
|
||||||
|
|
||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
cp mms2mail ~/.local/bin
|
cp mms2mail ~/.local/bin
|
||||||
|
@ -61,12 +30,6 @@ systemctl --user enable mms2mail
|
||||||
systemctl --user start mms2mail
|
systemctl --user start mms2mail
|
||||||
```
|
```
|
||||||
|
|
||||||
Depending on your distribution, you might have to add your account to the ```mail``` group to be able to lock and use the system mbox.
|
|
||||||
On Debian based distribution :
|
|
||||||
```
|
|
||||||
sudo addgroup $(whoami) mail
|
|
||||||
```
|
|
||||||
|
|
||||||
### config
|
### config
|
||||||
An optional configuration file can be put in the home folder : ```$HOME/.mms/modemmanager/mms2mail.ini```. The default value are :
|
An optional configuration file can be put in the home folder : ```$HOME/.mms/modemmanager/mms2mail.ini```. The default value are :
|
||||||
|
|
||||||
|
@ -76,40 +39,19 @@ mailbox = /var/mail/$USER ; the mailbox where mms are appended
|
||||||
user = $USER ; the user account specified as recipient
|
user = $USER ; the user account specified as recipient
|
||||||
domain = $HOSTNAME ; the domain part appended to phone number and user
|
domain = $HOSTNAME ; the domain part appended to phone number and user
|
||||||
attach_mms = false ; whether to attach the full mms binary file
|
attach_mms = false ; whether to attach the full mms binary file
|
||||||
delete_from_mmsd = false ; delete mms from mmsd storage upon successful conversion
|
|
||||||
|
|
||||||
[smtp]
|
|
||||||
hostname = localhost
|
|
||||||
port = 2525
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## usage
|
## usage
|
||||||
|
|
||||||
### reference
|
|
||||||
```
|
```
|
||||||
mms2mail [-h] [--disable-smtp] [--disable-mms-delivery] [--force-read] [--force-unlock] [-l {critical,error,warning,info,debug}]
|
mms2mail [-h] [-d [{dbus,filesystem}] | -f FILES [FILES ...]] [--delete] [--force-read]
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
--disable-smtp
|
-d [{dbus,filesystem}], --daemon [{dbus,filesystem}]
|
||||||
--disable-mms-delivery
|
Use dbus signal from mmsd by default but can also watch mmsd storage folder (useful for mmsd < 1.0)
|
||||||
|
-f FILES [FILES ...], --file FILES [FILES ...]
|
||||||
|
Parse specified mms files and quit
|
||||||
|
--delete Ask mmsd to delete the converted MMS
|
||||||
--force-read Force conversion even if MMS is marked as read
|
--force-read Force conversion even if MMS is marked as read
|
||||||
--force-unlock BEWARE COULD LEAD TO WHOLE MBOX CORRUPTION Force unlocking the mbox after a few minutes /!\
|
|
||||||
-l {critical,error,warning,info,debug}, --logging {critical,error,warning,info,debug}
|
|
||||||
Define the logger output level
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sending MMS
|
|
||||||
|
|
||||||
To send MMS, mail address not in the following format would be ignored :
|
|
||||||
```+123456789@domain``` with phone number in international format.
|
|
||||||
|
|
||||||
#### with Mutt :
|
|
||||||
To be able to send mms with mutt you need it to be built with SMTP support.
|
|
||||||
And and the following line in your ```$HOME/.muttrc```:
|
|
||||||
```
|
|
||||||
set smtp_url = "smtp://localhost:2525"
|
|
||||||
set ssl_starttls = no
|
|
||||||
set ssl_force_tls = no
|
|
||||||
|
|
||||||
```
|
```
|
9
TODO.md
9
TODO.md
|
@ -1,9 +0,0 @@
|
||||||
# mms2mail
|
|
||||||
|
|
||||||
## To Do
|
|
||||||
|
|
||||||
* HTML message
|
|
||||||
* mms2mail : add as HTML body, currently added as attachments
|
|
||||||
* mail2mms : convert to text plain in case of html only mail
|
|
||||||
|
|
||||||
* A lot of other things...
|
|
774
mms2mail
774
mms2mail
|
@ -1,45 +1,44 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
"""An mms to mail converter for mmsd."""
|
"""An mms to mail converter for mmsd."""
|
||||||
|
# upstream bug dirty fix
|
||||||
|
# https://github.com/marrow/mailer/issues/87#issuecomment-689586587
|
||||||
|
import sys
|
||||||
|
if sys.version_info[0] == 3 and sys.version_info[1] > 7:
|
||||||
|
sys.modules["cgi.parse_qsl"] = None
|
||||||
|
# upstream bug dirty fix
|
||||||
|
# https://github.com/marrow/mailer/issues/87#issuecomment-713319548
|
||||||
|
import base64
|
||||||
|
if sys.version_info[0] == 3 and sys.version_info[1] > 8:
|
||||||
|
def encodestring(value):
|
||||||
|
"""
|
||||||
|
Encode string in base64.
|
||||||
|
|
||||||
|
:param value: the string to encode.
|
||||||
|
:type value: str
|
||||||
|
|
||||||
|
:rtype str
|
||||||
|
:return: the base64 encoded string
|
||||||
|
"""
|
||||||
|
return base64.b64encode(value)
|
||||||
|
base64.encodestring = encodestring
|
||||||
|
# end bugfix
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
|
import re
|
||||||
|
import time
|
||||||
import getpass
|
import getpass
|
||||||
import socket
|
import socket
|
||||||
import mimetypes
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import mailbox
|
from messaging.mms.message import MMSMessage
|
||||||
import email
|
from marrow.mailer import Mailer, Message
|
||||||
|
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from pydbus import SessionBus
|
import dbus
|
||||||
from datetime import datetime
|
import dbus.mainloop.glib
|
||||||
|
from watchdog.observers import Observer
|
||||||
import os
|
from watchdog.events import FileSystemEventHandler
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
from aiosmtpd.controller import Controller
|
|
||||||
from email import parser
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
"""Allow sharing configuration between classes."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Return the config instance."""
|
|
||||||
self.config = configparser.ConfigParser()
|
|
||||||
self.config.read(f"{Path.home()}/.mms/modemmanager/mms2mail.ini")
|
|
||||||
|
|
||||||
def get_config(self):
|
|
||||||
"""Return the config element.
|
|
||||||
|
|
||||||
:rtype ConfigParser
|
|
||||||
:return The parsed configuration
|
|
||||||
"""
|
|
||||||
return self.config
|
|
||||||
|
|
||||||
|
|
||||||
class MMS2Mail:
|
class MMS2Mail:
|
||||||
|
@ -47,355 +46,44 @@ class MMS2Mail:
|
||||||
The class handling the conversion between MMS and mail format.
|
The class handling the conversion between MMS and mail format.
|
||||||
|
|
||||||
MMS support is provided by python-messaging
|
MMS support is provided by python-messaging
|
||||||
|
Mail support is provided by marrow.mailer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config, force_read=False,
|
def __init__(self, delete=False, force_read=False):
|
||||||
force_unlock=False):
|
|
||||||
"""
|
"""
|
||||||
Return class instance.
|
Return class instance.
|
||||||
|
|
||||||
:param config: The module configuration file
|
:param delete: delete MMS after conversion
|
||||||
:type config: ConfigParser
|
:type delete: bool
|
||||||
|
|
||||||
:param force_read: force converting an already read MMS (batch mode)
|
:param force_read: force converting an already read MMS (batch mode)
|
||||||
:type force_read: bool
|
:type force_read: bool
|
||||||
|
|
||||||
:param force_unlock: Force mbox unlocking after a few minutes
|
|
||||||
:type force_unlock: bool
|
|
||||||
"""
|
"""
|
||||||
|
self.delete = delete
|
||||||
self.force_read = force_read
|
self.force_read = force_read
|
||||||
self.force_unlock = force_unlock
|
self.config = configparser.ConfigParser()
|
||||||
cfg = config.get_config()
|
self.config.read(f"{Path.home()}/.mms/modemmanager/mms2mail.ini")
|
||||||
self.attach_mms = cfg.getboolean('mail', 'attach_mms',
|
self.attach_mms = self.config.getboolean('mail', 'attach_mms',
|
||||||
fallback=False)
|
fallback=False)
|
||||||
self.delete = cfg.getboolean('mail', 'delete_from_mmsd',
|
self.domain = self.config.get('mail', 'domain',
|
||||||
fallback=False)
|
|
||||||
self.domain = cfg.get('mail', 'domain',
|
|
||||||
fallback=socket.getfqdn())
|
fallback=socket.getfqdn())
|
||||||
self.user = cfg.get('mail', 'user', fallback=getpass.getuser())
|
self.user = self.config.get('mail', 'user', fallback=getpass.getuser())
|
||||||
mbox_file = cfg.get('mail', 'mailbox',
|
mbox_file = self.config.get('mail', 'mailbox',
|
||||||
fallback=f"/var/mail/{self.user}")
|
fallback=f"/var/mail/{self.user}")
|
||||||
self.mailbox = mailbox.mbox(mbox_file)
|
self.mailer = Mailer({'manager.use': 'immediate',
|
||||||
self.dbus = None
|
'transport.use': 'mbox',
|
||||||
|
'transport.file': mbox_file})
|
||||||
|
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
||||||
|
self.bus = dbus.SessionBus()
|
||||||
|
|
||||||
def set_dbus(self, dbusmmsd):
|
def get_bus(self):
|
||||||
"""
|
"""
|
||||||
Return the DBus SessionBus.
|
Return the DBus SessionBus.
|
||||||
|
|
||||||
:param dbusmmsd: The DBus MMSd abstraction class
|
:rtype dbus.SessionBus()
|
||||||
:type dbusmmsd: DbusMMSd()
|
:return: an active SessionBus
|
||||||
"""
|
"""
|
||||||
self.dbus = dbusmmsd
|
return self.bus
|
||||||
|
|
||||||
def check_mms(self, path, properties):
|
|
||||||
"""
|
|
||||||
Check wether the provided file would be converted.
|
|
||||||
|
|
||||||
:param path: the mms filesystem path
|
|
||||||
:type path: str
|
|
||||||
|
|
||||||
:param properties: the mms properties
|
|
||||||
:type properties: Array
|
|
||||||
|
|
||||||
:return the mms status or None
|
|
||||||
:rtype str
|
|
||||||
"""
|
|
||||||
# Check for mmsd data file
|
|
||||||
if not Path(f"{path}").is_file():
|
|
||||||
log.error("MMS file not found : aborting")
|
|
||||||
return None
|
|
||||||
# Check for mmsd status file
|
|
||||||
status = configparser.ConfigParser()
|
|
||||||
if not Path(f"{path}.status").is_file():
|
|
||||||
log.error("MMS status file not found : aborting")
|
|
||||||
return None
|
|
||||||
status.read_file(open(f"{path}.status"))
|
|
||||||
if not (self.force_read or not status.getboolean('info', 'read')):
|
|
||||||
log.error("Already converted MMS : aborting")
|
|
||||||
return None
|
|
||||||
return status['info']['state']
|
|
||||||
|
|
||||||
def message_added(self, name, value):
|
|
||||||
"""Trigger conversion on MessageAdded signal."""
|
|
||||||
if value['Status'] == 'downloaded' or value['Status'] == 'received':
|
|
||||||
log.debug(f"New incoming MMS found ({name.split('/')[-1]})")
|
|
||||||
self.convert(path=value['Attachments'][0][2], dbus_path=name,
|
|
||||||
properties=value)
|
|
||||||
else:
|
|
||||||
log.debug(f"New outgoing MMS found ({name.split('/')[-1]})")
|
|
||||||
|
|
||||||
def convert(self, path, dbus_path, properties):
|
|
||||||
"""
|
|
||||||
Convert a provided mms file to a mail stored in a mbox.
|
|
||||||
|
|
||||||
:param path: the mms filesystem path
|
|
||||||
:type path: str
|
|
||||||
|
|
||||||
:param dbus_path: the mms dbus path
|
|
||||||
:type dbus_path: str
|
|
||||||
|
|
||||||
:param properties: the mms properties
|
|
||||||
:type properties: Array
|
|
||||||
"""
|
|
||||||
# Check if the provided file present
|
|
||||||
status = self.check_mms(path, properties)
|
|
||||||
if not status:
|
|
||||||
log.error("MMS file not convertible.")
|
|
||||||
return
|
|
||||||
|
|
||||||
message = email.message.EmailMessage()
|
|
||||||
|
|
||||||
# Generate Mail Headers
|
|
||||||
mms_from = properties.get('Sender', "unknown")
|
|
||||||
log.debug(f"MMS[From]: {mms_from}")
|
|
||||||
if '@' in mms_from:
|
|
||||||
message['From'] = mms_from
|
|
||||||
else:
|
|
||||||
message['From'] = f"{mms_from}@{self.domain}"
|
|
||||||
|
|
||||||
to = properties.get('Modem Number', None)
|
|
||||||
if to:
|
|
||||||
message['To'] = f"{mms_from}@{self.domain}"
|
|
||||||
recipients = ""
|
|
||||||
for r in properties['Recipients']:
|
|
||||||
if to and to in r:
|
|
||||||
continue
|
|
||||||
log.debug(f'MMS[CC] : {r}')
|
|
||||||
if '@' in r:
|
|
||||||
recipients += f"{r},"
|
|
||||||
else:
|
|
||||||
recipients += f"{r}@{self.domain},"
|
|
||||||
if recipients:
|
|
||||||
recipients = recipients[:-1]
|
|
||||||
if to:
|
|
||||||
message['CC'] = recipients
|
|
||||||
else:
|
|
||||||
message['To'] = recipients
|
|
||||||
|
|
||||||
message['Subject'] = properties.get('Subject',
|
|
||||||
f"MMS from {mms_from}")
|
|
||||||
mms_date = properties.get('Date')
|
|
||||||
if mms_date:
|
|
||||||
mms_datetime = datetime.strptime(mms_date, '%Y-%m-%dT%H:%M:%S%z')
|
|
||||||
mail_date = email.utils.format_datetime(mms_datetime)
|
|
||||||
message['Date'] = mail_date or email.utils.formatdate()
|
|
||||||
|
|
||||||
message.preamble = "This mail is converted from a MMS."
|
|
||||||
body = ""
|
|
||||||
attachments = []
|
|
||||||
for attachment in properties['Attachments']:
|
|
||||||
cid = attachment[0]
|
|
||||||
mimetype = attachment[1]
|
|
||||||
contentfile = attachment[2]
|
|
||||||
offset = attachment[3]
|
|
||||||
size = attachment[4]
|
|
||||||
with open(contentfile, 'rb') as f:
|
|
||||||
f.seek(offset, 0)
|
|
||||||
content = f.read(size)
|
|
||||||
if mimetype is not None:
|
|
||||||
if 'text/plain' in mimetype:
|
|
||||||
mimetype, charset = mimetype.split(';', 1)
|
|
||||||
encoding = charset.split('=')[1]
|
|
||||||
body += content.decode(encoding,
|
|
||||||
errors='replace') + '\n'
|
|
||||||
continue
|
|
||||||
maintype, subtype = mimetype.split('/', 1)
|
|
||||||
extension = str(mimetypes.guess_extension(mimetype))
|
|
||||||
filename = cid
|
|
||||||
attachments.append([content, maintype,
|
|
||||||
subtype, filename + extension])
|
|
||||||
if body:
|
|
||||||
message.set_content(body)
|
|
||||||
for a in attachments:
|
|
||||||
message.add_attachment(a[0],
|
|
||||||
maintype=a[1],
|
|
||||||
subtype=a[2],
|
|
||||||
filename=a[3])
|
|
||||||
|
|
||||||
# Add MMS binary file, for debugging purpose
|
|
||||||
# or reparsing in the future
|
|
||||||
if self.attach_mms:
|
|
||||||
with open(path, 'rb') as fp:
|
|
||||||
message.add_attachment(fp.read(),
|
|
||||||
maintype='application',
|
|
||||||
subtype='octet-stream',
|
|
||||||
filename=path.split('/')[-1])
|
|
||||||
|
|
||||||
# Write the mail in case of mbox lock retry for 5 minutes
|
|
||||||
# Ultimately write in an mbox in the home folder
|
|
||||||
end_time = time.time() + (5 * 60)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
# self.mailer.send(message)
|
|
||||||
self.mailbox.lock()
|
|
||||||
self.mailbox.add(mailbox.mboxMessage(message))
|
|
||||||
self.mailbox.flush()
|
|
||||||
self.mailbox.unlock()
|
|
||||||
break
|
|
||||||
except (mailbox.ExternalClashError, FileExistsError) as e:
|
|
||||||
log.warn(f"Exception Mbox lock : {e}")
|
|
||||||
if time.time() > end_time:
|
|
||||||
if self.force_unlock:
|
|
||||||
log.error("Force removing lock")
|
|
||||||
self.mailbox.unlock()
|
|
||||||
else:
|
|
||||||
fs_mbox_path = f"{Path.home()}/.mms/failsafembox"
|
|
||||||
fs_mbox = mailbox.mbox(fs_mbox_path)
|
|
||||||
log.warning(f"Writing in internal mbox {fs_mbox_path}")
|
|
||||||
try:
|
|
||||||
fs_mbox.unlock()
|
|
||||||
fs_mbox.lock()
|
|
||||||
fs_mbox.add(mailbox.mboxMessage(str(message)))
|
|
||||||
fs_mbox.flush()
|
|
||||||
fs_mbox.unlock()
|
|
||||||
break
|
|
||||||
except (mailbox.ExternalClashError,
|
|
||||||
FileExistsError) as e:
|
|
||||||
log.error(f"Failsafe Mbox error : {e}")
|
|
||||||
log.error(f"MMS cannot be written to any mbox : \
|
|
||||||
{path.split('/')[-1]}")
|
|
||||||
finally:
|
|
||||||
break
|
|
||||||
|
|
||||||
else:
|
|
||||||
time.sleep(5)
|
|
||||||
# Ask mmsd to mark message as read and delete it
|
|
||||||
if properties:
|
|
||||||
self.dbus.mark_mms_read(dbus_path)
|
|
||||||
if self.delete:
|
|
||||||
self.dbus.delete_mms(dbus_path)
|
|
||||||
|
|
||||||
def convert_stored_mms(self):
|
|
||||||
"""Convert all mms from mmsd storage."""
|
|
||||||
log.info('INIT : Converting MMs from storage')
|
|
||||||
messages = self.dbus.get_messages()
|
|
||||||
for m in messages:
|
|
||||||
self.message_added(name=m[0], value=m[1])
|
|
||||||
|
|
||||||
|
|
||||||
class Mail2MMSHandler:
|
|
||||||
"""The class handling the conversion between mail and MMS format."""
|
|
||||||
|
|
||||||
def __init__(self, dbusmmsd):
|
|
||||||
"""
|
|
||||||
Return the Mail2MMS instance.
|
|
||||||
|
|
||||||
:param dbusmmsd: The DBus MMSd abstraction class
|
|
||||||
:type dbusmmsd: DbusMMSd()
|
|
||||||
|
|
||||||
:param config: The module configuration file
|
|
||||||
:type config: ConfigParser
|
|
||||||
"""
|
|
||||||
self.parser = parser.BytesParser()
|
|
||||||
self.pattern = re.compile('^\+[0-9]+$')
|
|
||||||
self.dbusmmsd = dbusmmsd
|
|
||||||
mmsd_config = dbusmmsd.get_manager_config()
|
|
||||||
self.auto_create_smil = mmsd_config.get('AutoCreateSMIL', False)
|
|
||||||
self.max_attachments = mmsd_config.get('MaxAttachments', 25)
|
|
||||||
self.total_max_attachment_size = mmsd_config.get(
|
|
||||||
'TotalMaxAttachmentSize',
|
|
||||||
1100000)
|
|
||||||
self.use_delivery_reports = mmsd_config.get('UseDeliveryReports',
|
|
||||||
False)
|
|
||||||
|
|
||||||
async def handle_DATA(self, server, session, envelope):
|
|
||||||
"""
|
|
||||||
Handle the reception of a new mail via smtp.
|
|
||||||
|
|
||||||
:param server: The SMTP server instance
|
|
||||||
:type server: SMTP
|
|
||||||
|
|
||||||
:param session: The session instance currently being handled
|
|
||||||
:type session: Session
|
|
||||||
|
|
||||||
:param envelope: The envelope instance of the current SMTP Transaction
|
|
||||||
:type envelope: Envelope
|
|
||||||
"""
|
|
||||||
recipients = []
|
|
||||||
attachments = []
|
|
||||||
smil = None
|
|
||||||
|
|
||||||
for r in envelope.rcpt_tos:
|
|
||||||
number = r.split('@')[0]
|
|
||||||
if self.pattern.search(number):
|
|
||||||
log.debug(f'Add recipient number : {number}')
|
|
||||||
recipients.append(number)
|
|
||||||
else:
|
|
||||||
log.debug(f'Ignoring recipient : {r}')
|
|
||||||
if len(recipients) == 0:
|
|
||||||
log.info('No sms recipient')
|
|
||||||
return '553 Requested action not taken: mailbox name not allowed'
|
|
||||||
|
|
||||||
mail = self.parser.parsebytes(envelope.content)
|
|
||||||
subject = mail.get('subject', failobj=None)
|
|
||||||
cid = 1
|
|
||||||
total_size = 0
|
|
||||||
with tempfile.TemporaryDirectory(prefix='mailtomms-') as tmp_dir:
|
|
||||||
for part in mail.walk():
|
|
||||||
content_type = part.get_content_type()
|
|
||||||
if 'multipart' in content_type:
|
|
||||||
continue
|
|
||||||
filename = part.get_filename()
|
|
||||||
if not filename:
|
|
||||||
ext = mimetypes.guess_extension(part.get_content_type())
|
|
||||||
if not ext:
|
|
||||||
# Use a generic bag-of-bits extension
|
|
||||||
ext = '.bin'
|
|
||||||
filename = f'part-{cid:03d}{ext}'
|
|
||||||
if filename == 'smil.xml':
|
|
||||||
smil = part.get_payload(decode=True)
|
|
||||||
continue
|
|
||||||
path = os.path.join(tmp_dir, filename)
|
|
||||||
if content_type == 'text/plain':
|
|
||||||
with open(path, 'wt', encoding='utf-8') as af:
|
|
||||||
charset = part.get_content_charset(failobj='utf-8')
|
|
||||||
total_size += af.write(part.
|
|
||||||
get_payload(decode=True).
|
|
||||||
decode(charset))
|
|
||||||
else:
|
|
||||||
with open(path, 'wb') as af:
|
|
||||||
total_size += af.write(part.
|
|
||||||
get_payload(decode=True))
|
|
||||||
attachments.append((f"cid-{cid}", content_type, path))
|
|
||||||
cid += 1
|
|
||||||
if len(attachments) == 0:
|
|
||||||
return '550 No attachments found'
|
|
||||||
elif len(attachments) > self.max_attachments:
|
|
||||||
return '550 Too much attachments'
|
|
||||||
elif total_size > self.total_max_attachment_size:
|
|
||||||
return '554 5.3.4 Message too big for system'
|
|
||||||
try:
|
|
||||||
self.dbusmmsd.send_mms(recipients=recipients,
|
|
||||||
attachments=attachments,
|
|
||||||
subject=subject,
|
|
||||||
smil=smil)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(e)
|
|
||||||
return '421 mmsd service not available'
|
|
||||||
return '250 OK'
|
|
||||||
|
|
||||||
|
|
||||||
class DbusMMSd():
|
|
||||||
"""Use DBus communication with mmsd."""
|
|
||||||
|
|
||||||
def __init__(self, mms2mail=None):
|
|
||||||
"""
|
|
||||||
Return a DBusWatcher instance.
|
|
||||||
|
|
||||||
:param mms2mail: An mms2mail instance to convert new mms
|
|
||||||
:type mms2mail: mms2mail()
|
|
||||||
"""
|
|
||||||
self.mms2mail = mms2mail
|
|
||||||
self.bus = SessionBus()
|
|
||||||
|
|
||||||
def set_mms2mail(self, mms2mail):
|
|
||||||
"""
|
|
||||||
Set mms2mail instance handling dbus event.
|
|
||||||
|
|
||||||
:param mms2mail: An mms2mail instance to convert new mms
|
|
||||||
:type mms2mail: mms2mail()
|
|
||||||
"""
|
|
||||||
self.mms2mail = mms2mail
|
|
||||||
|
|
||||||
def mark_mms_read(self, dbus_path):
|
def mark_mms_read(self, dbus_path):
|
||||||
"""
|
"""
|
||||||
|
@ -404,8 +92,10 @@ class DbusMMSd():
|
||||||
:param dbus_path: the mms dbus path
|
:param dbus_path: the mms dbus path
|
||||||
:type dbus_path: str
|
:type dbus_path: str
|
||||||
"""
|
"""
|
||||||
message = self.bus.get('org.ofono.mms', dbus_path)
|
message = dbus.Interface(self.bus.get_object('org.ofono.mms',
|
||||||
log.debug(f"Marking MMS as read {dbus_path}")
|
dbus_path),
|
||||||
|
'org.ofono.mms.Message')
|
||||||
|
print(f"Marking MMS as read {dbus_path}", file=sys.stderr)
|
||||||
message.MarkRead()
|
message.MarkRead()
|
||||||
|
|
||||||
def delete_mms(self, dbus_path):
|
def delete_mms(self, dbus_path):
|
||||||
|
@ -415,183 +105,225 @@ class DbusMMSd():
|
||||||
:param dbus_path: the mms dbus path
|
:param dbus_path: the mms dbus path
|
||||||
:type dbus_path: str
|
:type dbus_path: str
|
||||||
"""
|
"""
|
||||||
message = self.bus.get('org.ofono.mms', dbus_path)
|
message = dbus.Interface(self.bus.get_object('org.ofono.mms',
|
||||||
log.debug(f"Deleting MMS {dbus_path}")
|
dbus_path),
|
||||||
|
'org.ofono.mms.Message')
|
||||||
|
print(f"Deleting MMS {dbus_path}", file=sys.stderr)
|
||||||
message.Delete()
|
message.Delete()
|
||||||
|
|
||||||
def get_service(self):
|
def check_mms(self, path):
|
||||||
"""
|
"""
|
||||||
Get mmsd Service Interface.
|
Check wether the provided file would be converted.
|
||||||
|
|
||||||
:return the mmsd service
|
:param path: the mms filesystem path
|
||||||
:rtype dbus.Interface
|
:type path: str
|
||||||
"""
|
|
||||||
manager = self.bus.get('org.ofono.mms', '/org/ofono/mms')
|
|
||||||
services = manager.GetServices()
|
|
||||||
path = services[0][0]
|
|
||||||
service = self.bus.get('org.ofono.mms', path)
|
|
||||||
return service
|
|
||||||
|
|
||||||
def get_messages(self):
|
|
||||||
"""
|
|
||||||
Ask mmsd all stored mms.
|
|
||||||
|
|
||||||
:return all mms from mmsd storage
|
|
||||||
:rtype Array
|
|
||||||
"""
|
|
||||||
service = self.get_service()
|
|
||||||
return service.GetMessages()
|
|
||||||
|
|
||||||
def get_manager_config(self):
|
|
||||||
"""
|
|
||||||
Ask mmsd its properties.
|
|
||||||
|
|
||||||
:return the mmsd manager service properties
|
|
||||||
:rtype dict
|
|
||||||
"""
|
|
||||||
service = self.get_service()
|
|
||||||
return service.GetProperties()
|
|
||||||
|
|
||||||
def get_send_message_version(self):
|
|
||||||
"""
|
|
||||||
Ask mmsd its SendMessage method Signature.
|
|
||||||
|
|
||||||
:return true if mmsd is mmsd-tng allowing Subject in mms
|
|
||||||
:rtype bool
|
:rtype bool
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, 'mmsdtng'):
|
# Check for mmsd data file
|
||||||
from xml.dom import minidom
|
if not Path(f"{path}").is_file():
|
||||||
mmsdtng = False
|
print("MMS file not found : aborting", file=sys.stderr)
|
||||||
svc = self.get_service()
|
return False
|
||||||
i = svc.Introspect()
|
# Check for mmsd status file
|
||||||
dom = minidom.parseString(i)
|
status = configparser.ConfigParser()
|
||||||
for method in dom.getElementsByTagName('method'):
|
if not Path(f"{path}.status").is_file():
|
||||||
if method.getAttribute('name') == "SendMessage":
|
print("MMS status file not found : aborting", file=sys.stderr)
|
||||||
for arg in method.getElementsByTagName('arg'):
|
return False
|
||||||
if arg.getAttribute('name') == 'options':
|
status.read_file(open(f"{path}.status"))
|
||||||
mmsdtng = True
|
# Allow only incoming MMS for the time beeing
|
||||||
self.mmsdtng = mmsdtng
|
if not (status['info']['state'] == 'downloaded' or
|
||||||
return self.mmsdtng
|
status['info']['state'] == 'received'):
|
||||||
|
print("Outgoing MMS : aborting", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
if not (self.force_read or not status.getboolean('info', 'read')):
|
||||||
|
print("Already converted MMS : aborting", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def send_mms(self, recipients, attachments, subject=None, smil=None):
|
def convert(self, path, dbus_path=None):
|
||||||
"""
|
"""
|
||||||
Ask mmsd to send a MMS.
|
Convert a provided mms file to a mail stored in a mbox.
|
||||||
|
|
||||||
:param recipients: The mms recipients phone numbers
|
:param path: the mms filesystem path
|
||||||
:type recipients: Array(str)
|
:type path: str
|
||||||
|
|
||||||
:param attachments: The mms attachments [name, mime type, filepath]
|
:param dbus_path: the mms dbus path
|
||||||
:type attachments: Array(str,str,str)
|
:type dbus_path: str
|
||||||
|
|
||||||
:param smil: The Smil.xml content allowing MMS customization
|
|
||||||
:type smil: str
|
|
||||||
"""
|
"""
|
||||||
service = self.get_service()
|
# Check if the provided file present
|
||||||
|
if not self.check_mms(path):
|
||||||
|
print("MMS file not convertible.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
# Generate its dbus path, for future operation (mark as read, delete)
|
||||||
|
if not dbus_path:
|
||||||
|
dbus_path = f"/org/ofono/mms/modemmanager/{path.split('/')[-1]}"
|
||||||
|
|
||||||
mmsdtng = self.get_send_message_version()
|
mms = MMSMessage.from_file(path)
|
||||||
if mmsdtng:
|
|
||||||
log.debug("Using mmsd-tng as backend")
|
|
||||||
option_list = {}
|
|
||||||
if subject:
|
|
||||||
log.debug(f"MMS Subject = {subject}")
|
|
||||||
option_list['Subject'] = GLib.Variant('s', subject)
|
|
||||||
if smil:
|
|
||||||
log.debug("Send MMS as Related")
|
|
||||||
option_list['smil'] = GLib.Variant('s', smil)
|
|
||||||
options = GLib.Variant('a{sv}', option_list)
|
|
||||||
path = service.SendMessage(recipients, options,
|
|
||||||
attachments)
|
|
||||||
else:
|
|
||||||
log.debug("Using mmsd as backend")
|
|
||||||
if smil:
|
|
||||||
log.debug("Send MMS as Related")
|
|
||||||
else:
|
|
||||||
log.debug("Send MMS as Mixed")
|
|
||||||
smil = ""
|
|
||||||
path = service.SendMessage(recipients, smil,
|
|
||||||
attachments)
|
|
||||||
log.debug(path)
|
|
||||||
|
|
||||||
def add_signal_receiver(self):
|
self.mailer.start()
|
||||||
"""Add a signal receiver to the current bus."""
|
message = Message()
|
||||||
if self.mms2mail:
|
|
||||||
service = self.get_service()
|
# Generate Mail Headers
|
||||||
service.onMessageAdded = self.mms2mail.message_added
|
mms_from, mms_from_type = mms.headers.get('From',
|
||||||
|
'unknown/undef').split('/')
|
||||||
|
message.author = f"{mms_from}@{self.domain}"
|
||||||
|
mms_from, mms_from_type = mms.headers.get('To',
|
||||||
|
'unknown/undef').split('/')
|
||||||
|
message.to = f"{self.user}@{self.domain}"
|
||||||
|
|
||||||
|
if 'Subject' in mms.headers and mms.headers['Subject']:
|
||||||
|
message.subject = mms.headers['Subject']
|
||||||
|
else:
|
||||||
|
message.subject = f"MMS from {mms_from}"
|
||||||
|
|
||||||
|
if 'Date' in mms.headers and mms.headers['Date']:
|
||||||
|
message.date = mms.headers['Date']
|
||||||
|
|
||||||
|
# Recopy MMS HEADERS
|
||||||
|
for header in mms.headers:
|
||||||
|
message.headers.append((f"X-MMS-{header}",
|
||||||
|
f"{mms.headers[header]}"))
|
||||||
|
|
||||||
|
for data_part in mms.data_parts:
|
||||||
|
datacontent = data_part.headers['Content-Type']
|
||||||
|
if datacontent is not None:
|
||||||
|
if 'text/plain' in datacontent[0]:
|
||||||
|
message.plain = f"{data_part.data} \n"
|
||||||
|
if 'Name' in datacontent[1]:
|
||||||
|
filename = datacontent[1]['Name']
|
||||||
|
message.attach(filename, data_part.data)
|
||||||
|
# Ensure a proper body content in the resulting mail
|
||||||
|
if not message.plain:
|
||||||
|
message.plain = " "
|
||||||
|
# Add MMS binary file, for debugging purpose or reparsing in the future
|
||||||
|
if self.attach_mms:
|
||||||
|
message.attach(path, None, None, None, False, "mms.bin")
|
||||||
|
|
||||||
|
# Creating an empty file stating the mms as been converted
|
||||||
|
self.mark_mms_read(dbus_path)
|
||||||
|
|
||||||
|
if self.delete:
|
||||||
|
self.delete_mms(dbus_path)
|
||||||
|
|
||||||
|
# Write the mail
|
||||||
|
self.mailer.send(message)
|
||||||
|
self.mailer.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class FSWatcher:
|
||||||
|
"""
|
||||||
|
Use OS filesystem notification to watch for new MMS (DEPRECATED).
|
||||||
|
|
||||||
|
Events are send to the FSHandler class
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Path to modemmanager storage
|
||||||
|
mms_folder = f"{Path.home()}/.mms/modemmanager"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Construct an instance."""
|
||||||
|
self.observer = Observer()
|
||||||
|
self.patternold = re.compile('[0-9A-F]{40}$')
|
||||||
|
self.pattern = re.compile('[0-9a-f]{36}$')
|
||||||
|
|
||||||
|
def is_mmsd_mms_file(self, path):
|
||||||
|
"""
|
||||||
|
Test if the provided file seems to be a mms file created by mmsd.
|
||||||
|
|
||||||
|
:param path: the mms filesystem path
|
||||||
|
:type path: str
|
||||||
|
|
||||||
|
:rtype boolean
|
||||||
|
:return: the test result
|
||||||
|
"""
|
||||||
|
if self.pattern.search(path) or self.patternold.search(path):
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the dbus mainloop."""
|
"""Run the watcher mainloop."""
|
||||||
mainloop = GLib.MainLoop()
|
event_handler = FSHandler()
|
||||||
log.info("Starting DBus watcher mainloop")
|
self.observer.schedule(event_handler, self.mms_folder, recursive=False)
|
||||||
|
self.observer.start()
|
||||||
try:
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(5)
|
||||||
|
finally:
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join()
|
||||||
|
|
||||||
|
|
||||||
|
class FSHandler(FileSystemEventHandler):
|
||||||
|
"""Handle the FSWatcher event."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def on_any_event(event):
|
||||||
|
"""Trigger conversion on event by the FSWatcher."""
|
||||||
|
if event.is_directory:
|
||||||
|
return None
|
||||||
|
elif event.event_type == 'created' or event.event_type == 'modified':
|
||||||
|
if w.is_mmsd_mms_file(event.src_path):
|
||||||
|
print(f"New MMS found : {event.src_path}.", file=sys.stderr)
|
||||||
|
m.convert(event.src_path)
|
||||||
|
elif event.event_type == 'moved':
|
||||||
|
if w.is_mmsd_mms_file(event.dest_path):
|
||||||
|
print(f"New MMS found : {event.dest_path}.", file=sys.stderr)
|
||||||
|
m.convert(event.dest_path)
|
||||||
|
|
||||||
|
|
||||||
|
class DbusWatcher():
|
||||||
|
"""Use DBus Signal notification to watch for new MMS."""
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the watcher mainloop."""
|
||||||
|
bus = m.get_bus()
|
||||||
|
bus.add_signal_receiver(self.message_added,
|
||||||
|
bus_name="org.ofono.mms",
|
||||||
|
signal_name="MessageAdded",
|
||||||
|
member_keyword="member",
|
||||||
|
path_keyword="path",
|
||||||
|
interface_keyword="interface")
|
||||||
|
mainloop = GLib.MainLoop()
|
||||||
mainloop.run()
|
mainloop.run()
|
||||||
except KeyboardInterrupt:
|
|
||||||
log.info("Stopping DBus watcher mainloop")
|
|
||||||
mainloop.quit()
|
|
||||||
|
|
||||||
|
def message_added(self, name, value, member, path, interface):
|
||||||
def main():
|
"""Trigger conversion on MessageAdded signal."""
|
||||||
"""Run the different functions handling mms and mail."""
|
if value['Status'] == 'downloaded' or value['Status'] == 'received':
|
||||||
parser = argparse.ArgumentParser()
|
print(f"New incoming MMS found ({name.split('/')[-1]})")
|
||||||
parser.add_argument('--disable-smtp', action='store_true',
|
m.convert(value['Attachments'][0][2], name)
|
||||||
dest='disable_smtp')
|
else:
|
||||||
parser.add_argument('--disable-mms-delivery', action='store_true',
|
print(f"New outgoing MMS found ({name.split('/')[-1]})")
|
||||||
dest='disable_mms_delivery')
|
|
||||||
parser.add_argument('--force-read', action='store_true',
|
|
||||||
dest='force_read', help="Force conversion even if MMS \
|
|
||||||
is marked as read")
|
|
||||||
parser.add_argument('--force-unlock', action='store_true',
|
|
||||||
dest='force_unlock', help="BEWARE COULD LEAD TO \
|
|
||||||
WHOLE MBOX CORRUPTION \
|
|
||||||
Force unlocking the mbox \
|
|
||||||
after a few minutes /!\\")
|
|
||||||
parser.add_argument('-l', '--logging', dest='log_level', default='warning',
|
|
||||||
choices=['critical', 'error', 'warning',
|
|
||||||
'info', 'debug'],
|
|
||||||
help='Define the logger output level'
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
log.setLevel(args.log_level.upper())
|
|
||||||
ch = logging.StreamHandler()
|
|
||||||
ch.setLevel(logging.DEBUG)
|
|
||||||
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
formatter = logging.Formatter(log_format)
|
|
||||||
ch.setFormatter(formatter)
|
|
||||||
log.addHandler(ch)
|
|
||||||
|
|
||||||
c = Config()
|
|
||||||
d = DbusMMSd()
|
|
||||||
|
|
||||||
h = Mail2MMSHandler(dbusmmsd=d)
|
|
||||||
|
|
||||||
controller = Controller(h,
|
|
||||||
hostname=c.get_config().get('smtp', 'hostname',
|
|
||||||
fallback='localhost'),
|
|
||||||
port=c.get_config().get('smtp', 'port',
|
|
||||||
fallback=2525))
|
|
||||||
|
|
||||||
m = MMS2Mail(config=c,
|
|
||||||
force_read=args.force_read,
|
|
||||||
force_unlock=args.force_unlock)
|
|
||||||
m.set_dbus(d)
|
|
||||||
|
|
||||||
log.info("Starting mms2mail")
|
|
||||||
if not args.disable_smtp:
|
|
||||||
log.info("Activating smtp to mmsd server")
|
|
||||||
controller.start()
|
|
||||||
if not args.disable_mms_delivery:
|
|
||||||
log.info("Activating mms to mbox server")
|
|
||||||
d.set_mms2mail(m)
|
|
||||||
d.add_signal_receiver()
|
|
||||||
m.convert_stored_mms()
|
|
||||||
|
|
||||||
d.run()
|
|
||||||
controller.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
parser = argparse.ArgumentParser()
|
||||||
|
mode = parser.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument("-d", "--daemon",
|
||||||
|
help="Use dbus signal from mmsd by default but can also \
|
||||||
|
watch mmsd storage folder (useful for mmsd < 1.0)",
|
||||||
|
nargs="?", default="dbus",
|
||||||
|
choices=['dbus', 'filesystem'], dest='watcher')
|
||||||
|
mode.add_argument("-f", "--file", nargs='+',
|
||||||
|
help="Parse specified mms files and quit", dest='files')
|
||||||
|
parser.add_argument('--delete', action='store_true', dest='delete',
|
||||||
|
help="Ask mmsd to delete the converted MMS")
|
||||||
|
parser.add_argument('--force-read', action='store_true',
|
||||||
|
dest='force_read', help="Force conversion even if MMS \
|
||||||
|
is marked as read")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
m = MMS2Mail(args.delete, args.force_read)
|
||||||
|
|
||||||
|
if args.files:
|
||||||
|
for mms_file in args.files:
|
||||||
|
m.convert(mms_file)
|
||||||
|
elif args.watcher == 'dbus':
|
||||||
|
print("Starting mms2mail in daemon mode with dbus watcher")
|
||||||
|
w = DbusWatcher()
|
||||||
|
w.run()
|
||||||
|
elif args.watcher == 'filesystem':
|
||||||
|
print("Starting mms2mail in daemon mode with filesystem watcher")
|
||||||
|
w = FSWatcher()
|
||||||
|
w.run()
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
|
@ -3,8 +3,3 @@ mailbox = /var/mail/mobian
|
||||||
account = mobian
|
account = mobian
|
||||||
domain = mobian.lan
|
domain = mobian.lan
|
||||||
attach_mms = false
|
attach_mms = false
|
||||||
delete_from_mmsd = false
|
|
||||||
|
|
||||||
[smtp]
|
|
||||||
hostname = localhost
|
|
||||||
port = 2525
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ Description=Multimedia Messaging Service to Mail converter Daemon
|
||||||
After=mmsd.service
|
After=mmsd.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=python3 %h/.local/bin/mms2mail
|
ExecStart=python3 %h/.local/bin/mms2mail -d dbus
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10s
|
RestartSec=10s
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
pydbus
|
|
||||||
aiosmtpd
|
|
Loading…
Reference in a new issue