mms2mail/mms2mail

617 lines
22 KiB
Python
Executable file

#!/usr/bin/python3
"""An mms to mail converter for mmsd."""
import argparse
import configparser
import getpass
import socket
import mimetypes
import time
import logging
from pathlib import Path
from messaging.mms.message import MMSMessage
import mailbox
import email
from gi.repository import GLib
import pydbus
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:
"""
The class handling the conversion between MMS and mail format.
MMS support is provided by python-messaging
"""
def __init__(self, config, delete=False, force_read=False,
force_unlock=False):
"""
Return class instance.
:param config: The module configuration file
:type config: ConfigParser
:param delete: delete MMS after conversion
:type delete: bool
: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.delete = delete
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.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.
:param dbusmmsd: The DBus MMSd abstraction class
:type dbusmmsd: DbusMMSd()
"""
self.dbus = dbusmmsd
def check_mms(self, path):
"""
Check wether the provided file would be converted.
:param path: the mms filesystem path
:type path: str
: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=None, properties=None):
"""
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
"""
# Check if the provided file present
status = self.check_mms(path)
if not status:
log.error("MMS file not convertible.")
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)
message = email.message.EmailMessage()
# Generate Mail Headers
mms_h_from = mms.headers.get('From', 'unknown/undef')
log.debug(f"MMS[From]: {mms_h_from}")
if 'not inserted' in mms_h_from:
mms_h_from = 'unknown/undef'
mms_from, mms_from_type = mms_h_from.split('/')
message['From'] = f"{mms_from}@{self.domain}"
mms_h_to = mms.headers.get('To', 'unknown/undef')
log.debug(f"MMS[To]: {mms_h_to}")
if 'not inserted' in mms_h_to:
mms_h_to = 'unknown/undef'
mms_to, mms_to_type = mms_h_to.split('/')
message['To'] = f"{mms_to}@{self.domain}"
# Get other recipients from dbus signal
# https://github.com/pmarti/python-messaging/issues/49
if properties:
cc = ""
for r in properties['Recipients']:
if mms_to in r:
continue
log.debug(f'MMS/MAIL CC : {r}')
cc += f"{r}@{self.domain},"
if cc:
cc = cc[:-1]
message['CC'] = cc
if 'Subject' in mms.headers and mms.headers['Subject']:
message['Subject'] = mms.headers['Subject']
else:
if status == 'sent' or status == 'draft':
message['Subject'] = f"MMS to {mms_to}"
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.add_header(f"X-MMS-{header}", f"{mms.headers[header]}")
message.preamble = "This mail is converted from a MMS."
body = ""
data_id = 1
attachments = []
for data_part in mms.data_parts:
datacontent = data_part.headers['Content-Type']
if datacontent is not None:
maintype, subtype = datacontent[0].split('/', 1)
if 'text/plain' in datacontent[0]:
encoding = datacontent[1].get('Charset', 'utf-8')
body += data_part.data.decode(encoding,
errors='replace') + '\n'
continue
extension = str(mimetypes.guess_extension(datacontent[0]))
filename = datacontent[1].get('Name', str(data_id))
attachments.append([data_part.data, maintype,
subtype, filename + extension])
data_id = data_id + 1
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 = pydbus.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()
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')
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
"""
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):
"""
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)
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):
"""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
def run(self):
"""Run the dbus mainloop."""
mainloop = GLib.MainLoop()
log.info("Starting DBus watcher mainloop")
try:
mainloop.run()
except KeyboardInterrupt:
log.info("Stopping DBus watcher mainloop")
mainloop.quit()
def main():
"""Run the different functions handling mms and mail."""
parser = argparse.ArgumentParser()
mode = parser.add_mutually_exclusive_group()
mode.add_argument("-d", "--daemon",
help="start in daemon mode ",
action='store_true', dest='daemon')
mode.add_argument("-f", "--file", nargs='+',
help="Start in batch mode, parse specified mms files",
dest='files')
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('--delete', action='store_true', dest='delete',
help="After procession ask mmsd to delete \
the mms from its storage")
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,
delete=args.delete,
force_read=args.force_read,
force_unlock=args.force_unlock)
m.set_dbus(d)
if args.files:
for mms_file in args.files:
m.convert(path=mms_file)
return
elif args.daemon:
log.info("Starting mms2mail in daemon mode")
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()
else:
parser.print_help()
return
d.run()
controller.stop()
if __name__ == '__main__':
main()