mms2mail/mms2mail

598 lines
21 KiB
Plaintext
Raw Normal View History

2021-05-01 06:43:13 +00:00
#!/usr/bin/python3
2021-05-05 03:49:38 +00:00
"""An mms to mail converter for mmsd."""
2021-05-01 06:43:13 +00:00
import argparse
import configparser
import getpass
import socket
2021-05-06 20:12:10 +00:00
import mimetypes
import time
2021-05-10 08:30:37 +00:00
import logging
2021-05-01 06:43:13 +00:00
from pathlib import Path
import mailbox
2021-05-10 08:30:37 +00:00
import email
2021-05-01 06:43:13 +00:00
2021-05-05 03:49:38 +00:00
from gi.repository import GLib
2021-05-19 17:21:20 +00:00
from pydbus import SessionBus
from datetime import datetime
2021-05-01 06:43:13 +00:00
2021-05-11 14:52:27 +00:00
import os
import re
import tempfile
from aiosmtpd.controller import Controller
from email import parser
2021-05-10 08:30:37 +00:00
log = logging.getLogger(__name__)
2021-05-05 03:49:38 +00:00
2021-05-11 14:52:27 +00:00
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
2021-05-01 06:43:13 +00:00
class MMS2Mail:
"""
2021-05-05 03:49:38 +00:00
The class handling the conversion between MMS and mail format.
2021-05-01 06:43:13 +00:00
MMS support is provided by python-messaging
"""
2021-05-05 03:49:38 +00:00
def __init__(self, config, force_read=False,
2021-05-10 08:30:37 +00:00
force_unlock=False):
"""
Return class instance.
2021-05-11 14:52:27 +00:00
:param config: The module configuration file
:type config: ConfigParser
:param force_read: force converting an already read MMS (batch mode)
:type force_read: bool
:param force_unlock: Force mbox unlocking after a few minutes
:type force_unlock: bool
"""
self.force_read = force_read
self.force_unlock = force_unlock
2021-05-11 14:52:27 +00:00
cfg = config.get_config()
self.attach_mms = cfg.getboolean('mail', 'attach_mms',
fallback=False)
self.delete = cfg.getboolean('mail', 'delete_from_mmsd',
2021-05-19 17:21:20 +00:00
fallback=False)
2021-05-11 14:52:27 +00:00
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
2021-05-01 06:43:13 +00:00
def set_dbus(self, dbusmmsd):
"""
2021-05-05 03:49:38 +00:00
Return the DBus SessionBus.
:param dbusmmsd: The DBus MMSd abstraction class
:type dbusmmsd: DbusMMSd()
"""
self.dbus = dbusmmsd
2021-05-19 17:21:20 +00:00
def check_mms(self, path, properties):
"""
Check wether the provided file would be converted.
:param path: the mms filesystem path
:type path: str
2021-05-10 08:30:37 +00:00
2021-05-19 17:21:20 +00:00
:param properties: the mms properties
:type properties: Array
2021-05-10 08:30:37 +00:00
: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")
2021-05-10 08:30:37 +00:00
return None
# Check for mmsd status file
2021-05-01 06:43:13 +00:00
status = configparser.ConfigParser()
if not Path(f"{path}.status").is_file():
log.error("MMS status file not found : aborting")
2021-05-10 08:30:37 +00:00
return None
2021-05-01 06:43:13 +00:00
status.read_file(open(f"{path}.status"))
if not (self.force_read or not status.getboolean('info', 'read')):
log.error("Already converted MMS : aborting")
2021-05-10 08:30:37 +00:00
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]})")
2021-05-10 08:30:37 +00:00
self.convert(path=value['Attachments'][0][2], dbus_path=name,
properties=value)
else:
log.debug(f"New outgoing MMS found ({name.split('/')[-1]})")
2021-05-19 17:21:20 +00:00
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
2021-05-19 17:21:20 +00:00
:param properties: the mms properties
:type properties: Array
"""
# Check if the provided file present
2021-05-19 17:21:20 +00:00
status = self.check_mms(path, properties)
2021-05-10 08:30:37 +00:00
if not status:
log.error("MMS file not convertible.")
2021-05-01 06:43:13 +00:00
return
2021-05-10 08:30:37 +00:00
message = email.message.EmailMessage()
2021-05-01 06:43:13 +00:00
# Generate Mail Headers
2021-05-19 17:21:20 +00:00
mms_from = properties.get('Sender', "unknown")
log.debug(f"MMS[From]: {mms_from}")
if '@' in mms_from:
message['From'] = mms_from
2021-05-05 03:49:38 +00:00
else:
2021-05-19 17:21:20 +00:00
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},"
2021-05-10 08:30:37 +00:00
else:
2021-05-19 17:21:20 +00:00
recipients += f"{r}@{self.domain},"
if recipients:
recipients = recipients[:-1]
if to:
message['CC'] = recipients
else:
message['To'] = recipients
2021-05-01 21:11:15 +00:00
2021-05-19 17:21:20 +00:00
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()
2021-05-05 03:49:38 +00:00
2021-05-10 08:30:37 +00:00
message.preamble = "This mail is converted from a MMS."
body = ""
attachments = []
2021-05-19 17:21:20 +00:00
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'
2021-05-06 20:12:10 +00:00
continue
2021-05-19 17:21:20 +00:00
maintype, subtype = mimetype.split('/', 1)
extension = str(mimetypes.guess_extension(mimetype))
filename = cid
attachments.append([content, maintype,
2021-05-10 08:30:37 +00:00
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])
2021-05-06 20:12:10 +00:00
2021-05-19 17:21:20 +00:00
# Add MMS binary file, for debugging purpose
# or reparsing in the future
if self.attach_mms:
2021-05-10 08:30:37 +00:00
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()
2021-05-10 08:30:37 +00:00
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
2021-05-10 08:30:37 +00:00
if properties:
self.dbus.mark_mms_read(dbus_path)
if self.delete:
self.dbus.delete_mms(dbus_path)
2021-05-19 06:08:09 +00:00
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])
2021-05-05 03:49:38 +00:00
2021-05-11 14:52:27 +00:00
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
2021-05-11 14:52:27 +00:00
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)
2021-05-11 14:52:27 +00:00
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:
2021-05-11 14:52:27 +00:00
log.error(e)
return '421 mmsd service not available'
return '250 OK'
class DbusMMSd():
"""Use DBus communication with mmsd."""
def __init__(self, mms2mail=None):
"""
2021-05-07 10:12:08 +00:00
Return a DBusWatcher instance.
2021-05-07 10:12:08 +00:00
:param mms2mail: An mms2mail instance to convert new mms
:type mms2mail: mms2mail()
"""
2021-05-07 10:12:08 +00:00
self.mms2mail = mms2mail
2021-05-19 17:21:20 +00:00
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):
"""
Ask mmsd to mark the mms as read.
:param dbus_path: the mms dbus path
:type dbus_path: str
"""
message = self.bus.get('org.ofono.mms', dbus_path)
log.debug(f"Marking MMS as read {dbus_path}")
message.MarkRead()
def delete_mms(self, dbus_path):
"""
Ask mmsd to delete the mms.
:param dbus_path: the mms dbus path
:type dbus_path: str
"""
message = self.bus.get('org.ofono.mms', dbus_path)
log.debug(f"Deleting MMS {dbus_path}")
message.Delete()
2021-05-11 14:52:27 +00:00
def get_service(self):
"""
Get mmsd Service Interface.
:return the mmsd service
:rtype dbus.Interface
"""
manager = self.bus.get('org.ofono.mms', '/org/ofono/mms')
2021-05-11 14:52:27 +00:00
services = manager.GetServices()
path = services[0][0]
service = self.bus.get('org.ofono.mms', path)
2021-05-11 14:52:27 +00:00
return service
2021-05-19 06:08:09 +00:00
def get_messages(self):
"""
Ask mmsd all stored mms.
:return all mms from mmsd storage
:rtype Array
"""
service = self.get_service()
return service.GetMessages()
2021-05-11 14:52:27 +00:00
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
"""
if not hasattr(self, 'mmsdtng'):
from xml.dom import minidom
mmsdtng = False
svc = self.get_service()
i = svc.Introspect()
dom = minidom.parseString(i)
for method in dom.getElementsByTagName('method'):
if method.getAttribute('name') == "SendMessage":
for arg in method.getElementsByTagName('arg'):
if arg.getAttribute('name') == 'options':
mmsdtng = True
self.mmsdtng = mmsdtng
return self.mmsdtng
def send_mms(self, recipients, attachments, subject=None, smil=None):
2021-05-11 14:52:27 +00:00
"""
Ask mmsd to send a MMS.
:param recipients: The mms recipients phone numbers
:type recipients: Array(str)
:param attachments: The mms attachments [name, mime type, filepath]
:type attachments: Array(str,str,str)
:param smil: The Smil.xml content allowing MMS customization
:type smil: str
"""
service = self.get_service()
mmsdtng = self.get_send_message_version()
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)
2021-05-11 14:52:27 +00:00
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)
2021-05-11 14:52:27 +00:00
log.debug(path)
def add_signal_receiver(self):
"""Add a signal receiver to the current bus."""
if self.mms2mail:
service = self.get_service()
service.onMessageAdded = self.mms2mail.message_added
return True
else:
return False
2021-05-05 03:49:38 +00:00
def run(self):
"""Run the dbus mainloop."""
mainloop = GLib.MainLoop()
log.info("Starting DBus watcher mainloop")
2021-05-07 10:12:08 +00:00
try:
mainloop.run()
except KeyboardInterrupt:
log.info("Stopping DBus watcher mainloop")
2021-05-07 10:12:08 +00:00
mainloop.quit()
2021-05-05 03:49:38 +00:00
2021-05-07 10:12:08 +00:00
def main():
"""Run the different functions handling mms and mail."""
2021-05-01 06:43:13 +00:00
parser = argparse.ArgumentParser()
2021-05-11 14:52:27 +00:00
parser.add_argument('--disable-smtp', action='store_true',
dest='disable_smtp')
parser.add_argument('--disable-mms-delivery', action='store_true',
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 /!\\")
2021-05-10 08:30:37 +00:00
parser.add_argument('-l', '--logging', dest='log_level', default='warning',
choices=['critical', 'error', 'warning',
'info', 'debug'],
help='Define the logger output level'
)
2021-05-01 06:43:13 +00:00
args = parser.parse_args()
2021-05-10 08:30:37 +00:00
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)
2021-05-11 14:52:27 +00:00
c = Config()
d = DbusMMSd()
2021-05-11 14:52:27 +00:00
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,
2021-05-10 08:30:37 +00:00
force_unlock=args.force_unlock)
m.set_dbus(d)
2021-05-05 03:49:38 +00:00
2021-05-19 17:21:20 +00:00
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()
2021-05-11 14:52:27 +00:00
d.run()
2021-05-11 14:52:27 +00:00
controller.stop()
2021-05-07 10:12:08 +00:00
if __name__ == '__main__':
main()