Add MMS sending capacilities #1

Merged
alex merged 12 commits from dev into master 2021-05-21 05:57:05 +00:00
8 changed files with 630 additions and 273 deletions

2
.gitignore vendored
View file

@ -129,3 +129,5 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
#VScode
.vscode/

18
Makefile Normal file
View file

@ -0,0 +1,18 @@
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

View file

@ -1,22 +1,53 @@
# mms2mail # mms2mail
Convert MMSd MMS file to mbox. Mail bridge for mmsd.
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-watchdog (pip install watchdog) - python3-aiosmtpd
- python-messaging (pip install python-messaging) - python3-pydbus
- marrow.mailer (pip install marrow.mailer)
### setup ### setup
Install the dependency and mms2mail:
Install the dependency and mms2mail (on debian based distribution):
``` ```
pip install --user watchdog # or sudo apt install python3-watchdog make deb-deps install
pip install --user marrow-mailer ```
pip install --user python-messaging For other distribution:
```
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
@ -29,6 +60,12 @@ systemctl --user daemon-reload
systemctl --user enable mms2mail 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 :
@ -39,19 +76,40 @@ 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] [-d [{dbus,filesystem}] | -f FILES [FILES ...]] [--delete] [--force-read] mms2mail [-h] [--disable-smtp] [--disable-mms-delivery] [--force-read] [--force-unlock] [-l {critical,error,warning,info,debug}]
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
-d [{dbus,filesystem}], --daemon [{dbus,filesystem}] --disable-smtp
Use dbus signal from mmsd by default but can also watch mmsd storage folder (useful for mmsd < 1.0) --disable-mms-delivery
-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 Normal file
View file

@ -0,0 +1,9 @@
# 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...

777
mms2mail
View file

@ -1,45 +1,45 @@
#!/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 mimetypes
import time
import logging
from pathlib import Path from pathlib import Path
from messaging.mms.message import MMSMessage import mailbox
from marrow.mailer import Mailer, Message import email
from gi.repository import GLib from gi.repository import GLib
import dbus from pydbus import SessionBus
import dbus.mainloop.glib from datetime import datetime
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler import os
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,44 +47,355 @@ 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, delete=False, force_read=False): def __init__(self, config, force_read=False,
force_unlock=False):
""" """
Return class instance. Return class instance.
:param delete: delete MMS after conversion :param config: The module configuration file
:type delete: bool :type config: ConfigParser
: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
"""
self.delete = delete
self.force_read = force_read
self.config = configparser.ConfigParser()
self.config.read(f"{Path.home()}/.mms/modemmanager/mms2mail.ini")
self.attach_mms = self.config.getboolean('mail', 'attach_mms',
fallback=False)
self.domain = self.config.get('mail', 'domain',
fallback=socket.getfqdn())
self.user = self.config.get('mail', 'user', fallback=getpass.getuser())
mbox_file = self.config.get('mail', 'mailbox',
fallback=f"/var/mail/{self.user}")
self.mailer = Mailer({'manager.use': 'immediate',
'transport.use': 'mbox',
'transport.file': mbox_file})
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
self.bus = dbus.SessionBus()
def get_bus(self): :param force_unlock: Force mbox unlocking after a few minutes
:type force_unlock: bool
"""
self.force_read = force_read
self.force_unlock = force_unlock
cfg = config.get_config()
self.attach_mms = cfg.getboolean('mail', 'attach_mms',
fallback=False)
self.delete = cfg.getboolean('mail', 'delete_from_mmsd',
fallback=False)
self.domain = cfg.get('mail', 'domain',
fallback=socket.getfqdn())
self.user = cfg.get('mail', 'user', fallback=getpass.getuser())
mbox_file = cfg.get('mail', 'mailbox',
fallback=f"/var/mail/{self.user}")
self.mailbox = mailbox.mbox(mbox_file)
self.dbus = None
def set_dbus(self, dbusmmsd):
""" """
Return the DBus SessionBus. Return the DBus SessionBus.
:rtype dbus.SessionBus() :param dbusmmsd: The DBus MMSd abstraction class
:return: an active SessionBus :type dbusmmsd: DbusMMSd()
""" """
return self.bus self.dbus = dbusmmsd
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):
""" """
@ -93,10 +404,8 @@ class MMS2Mail:
:param dbus_path: the mms dbus path :param dbus_path: the mms dbus path
:type dbus_path: str :type dbus_path: str
""" """
message = dbus.Interface(self.bus.get_object('org.ofono.mms', message = self.bus.get('org.ofono.mms', dbus_path)
dbus_path), log.debug(f"Marking MMS as read {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):
@ -106,229 +415,183 @@ class MMS2Mail:
:param dbus_path: the mms dbus path :param dbus_path: the mms dbus path
:type dbus_path: str :type dbus_path: str
""" """
message = dbus.Interface(self.bus.get_object('org.ofono.mms', message = self.bus.get('org.ofono.mms', dbus_path)
dbus_path), log.debug(f"Deleting MMS {dbus_path}")
'org.ofono.mms.Message')
print(f"Deleting MMS {dbus_path}", file=sys.stderr)
message.Delete() message.Delete()
def check_mms(self, path): def get_service(self):
""" """
Check wether the provided file would be converted. Get mmsd Service Interface.
:param path: the mms filesystem path :return the mmsd service
:type path: str :rtype dbus.Interface
"""
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
""" """
# Check for mmsd data file if not hasattr(self, 'mmsdtng'):
if not Path(f"{path}").is_file(): from xml.dom import minidom
print("MMS file not found : aborting", file=sys.stderr) mmsdtng = False
return False svc = self.get_service()
# Check for mmsd status file i = svc.Introspect()
status = configparser.ConfigParser() dom = minidom.parseString(i)
if not Path(f"{path}.status").is_file(): for method in dom.getElementsByTagName('method'):
print("MMS status file not found : aborting", file=sys.stderr) if method.getAttribute('name') == "SendMessage":
return False for arg in method.getElementsByTagName('arg'):
status.read_file(open(f"{path}.status")) if arg.getAttribute('name') == 'options':
# Allow only incoming MMS for the time beeing mmsdtng = True
if not (status['info']['state'] == 'downloaded' or self.mmsdtng = mmsdtng
status['info']['state'] == 'received'): return self.mmsdtng
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 convert(self, path, dbus_path=None): def send_mms(self, recipients, attachments, subject=None, smil=None):
""" """
Convert a provided mms file to a mail stored in a mbox. Ask mmsd to send a MMS.
:param path: the mms filesystem path :param recipients: The mms recipients phone numbers
:type path: str :type recipients: Array(str)
:param dbus_path: the mms dbus path :param attachments: The mms attachments [name, mime type, filepath]
:type dbus_path: str :type attachments: Array(str,str,str)
:param smil: The Smil.xml content allowing MMS customization
:type smil: str
""" """
# Check if the provided file present service = self.get_service()
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]}"
mms = MMSMessage.from_file(path) mmsdtng = self.get_send_message_version()
if mmsdtng:
self.mailer.start() log.debug("Using mmsd-tng as backend")
message = Message() option_list = {}
if subject:
# Generate Mail Headers log.debug(f"MMS Subject = {subject}")
mms_from, mms_from_type = mms.headers.get('From', option_list['Subject'] = GLib.Variant('s', subject)
'unknown/undef').split('/') if smil:
message.author = f"{mms_from}@{self.domain}" log.debug("Send MMS as Related")
mms_from, mms_from_type = mms.headers.get('To', option_list['smil'] = GLib.Variant('s', smil)
'unknown/undef').split('/') options = GLib.Variant('a{sv}', option_list)
message.to = f"{self.user}@{self.domain}" path = service.SendMessage(recipients, options,
attachments)
if 'Subject' in mms.headers and mms.headers['Subject']:
message.subject = mms.headers['Subject']
else: else:
message.subject = f"MMS from {mms_from}" 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)
if 'Date' in mms.headers and mms.headers['Date']: def add_signal_receiver(self):
message.date = mms.headers['Date'] """Add a signal receiver to the current bus."""
if self.mms2mail:
# Recopy MMS HEADERS service = self.get_service()
for header in mms.headers: service.onMessageAdded = self.mms2mail.message_added
message.headers.append((f"X-MMS-{header}",
f"{mms.headers[header]}"))
message.plain = " "
data_id = 1
for data_part in mms.data_parts:
datacontent = data_part.headers['Content-Type']
if datacontent is not None:
if 'text/plain' in datacontent[0]:
encoding = datacontent[1].get('Charset', 'utf-8')
plain = data_part.data.decode(encoding)
message.plain += plain + '\n'
continue
extension = mimetypes.guess_extension(datacontent[0])
filename = datacontent[1].get('Name', str(data_id))
message.attach(filename + extension, data_part.data)
data_id = data_id + 1
# Add MMS binary file, for debugging purpose or reparsing in the future
if self.attach_mms:
message.attach(path, None, None, None, False, path.split('/')[-1])
# 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 watcher mainloop.""" """Run the dbus mainloop."""
event_handler = FSHandler()
self.observer.schedule(event_handler, self.mms_folder, recursive=False)
self.observer.start()
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 = GLib.MainLoop()
mainloop.run() log.info("Starting DBus watcher mainloop")
try:
def message_added(self, name, value, member, path, interface): mainloop.run()
"""Trigger conversion on MessageAdded signal.""" except KeyboardInterrupt:
if value['Status'] == 'downloaded' or value['Status'] == 'received': log.info("Stopping DBus watcher mainloop")
print(f"New incoming MMS found ({name.split('/')[-1]})") mainloop.quit()
m.convert(value['Attachments'][0][2], name)
else:
print(f"New outgoing MMS found ({name.split('/')[-1]})")
if __name__ == '__main__': def main():
"""Run the different functions handling mms and mail."""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
mode = parser.add_mutually_exclusive_group() parser.add_argument('--disable-smtp', action='store_true',
mode.add_argument("-d", "--daemon", dest='disable_smtp')
help="Use dbus signal from mmsd by default but can also \ parser.add_argument('--disable-mms-delivery', action='store_true',
watch mmsd storage folder (useful for mmsd < 1.0)", dest='disable_mms_delivery')
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', parser.add_argument('--force-read', action='store_true',
dest='force_read', help="Force conversion even if MMS \ dest='force_read', help="Force conversion even if MMS \
is marked as read") 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() args = parser.parse_args()
m = MMS2Mail(args.delete, args.force_read) 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)
if args.files: c = Config()
for mms_file in args.files: d = DbusMMSd()
m.convert(mms_file)
elif args.watcher == 'dbus': h = Mail2MMSHandler(dbusmmsd=d)
print("Starting mms2mail in daemon mode with dbus watcher")
w = DbusWatcher() controller = Controller(h,
w.run() hostname=c.get_config().get('smtp', 'hostname',
elif args.watcher == 'filesystem': fallback='localhost'),
print("Starting mms2mail in daemon mode with filesystem watcher") port=c.get_config().get('smtp', 'port',
w = FSWatcher() fallback=2525))
w.run()
else: m = MMS2Mail(config=c,
parser.print_help() 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__':
main()

View file

@ -3,3 +3,8 @@ 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

View file

@ -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 -d dbus ExecStart=python3 %h/.local/bin/mms2mail
Restart=on-failure Restart=on-failure
RestartSec=10s RestartSec=10s

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
pydbus
aiosmtpd