From cc30d699120e2edebc4d8e3f4392f2e5ec031594 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 May 2021 16:52:27 +0200 Subject: [PATCH] [feat] SMTP server to send MMS --- README.md | 47 +++++++--- TODO.md | 11 +++ mms2mail | 251 ++++++++++++++++++++++++++++++++++++++++++++++----- mms2mail.ini | 4 + 4 files changed, 278 insertions(+), 35 deletions(-) create mode 100644 TODO.md diff --git a/README.md b/README.md index b0eec5a..0240682 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,29 @@ # mms2mail -Convert mmsd MMS file to mbox. +Mail bridge for mmsd. -mms2mail can convert mms in batch mode, or wait for new mms via a dbus signal -sent by mmsd. -By default it store them 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``` +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 ### dependency - python3 + - python3-aiosmtpd - python-messaging (pip install python-messaging) ### setup -Install the dependency and mms2mail: +Install the dependency and mms2mail (on debian based distribution): ``` sudo apt-get install python3 +sudo apt-get install python3-aiosmtpd pip install --user python-messaging mkdir -p ~/.local/bin @@ -42,22 +47,38 @@ mailbox = /var/mail/$USER ; the mailbox where mms are appended user = $USER ; the user account specified as recipient domain = $HOSTNAME ; the domain part appended to phone number and user attach_mms = false ; whether to attach the full mms binary file + +[smtp] +hostname = localhost +port = 2525 ``` ## usage + +### reference ``` -mms2mail [-h] [-d | -f FILES [FILES ...]] [--delete] [--force-read] - [--force-unlock] [-l {critical,error,warning,info,debug}] + mms2mail [-h] [-d | -f FILES [FILES ...]] [--disable-smtp] [--disable-mms-delivery] [--delete] [--force-read] [--force-unlock] [-l {critical,error,warning,info,debug}] optional arguments: -h, --help show this help message and exit - -d, --daemon Use dbus signal from mmsd to trigger conversion + -d, --daemon start in daemon mode -f FILES [FILES ...], --file FILES [FILES ...] - Parse specified mms files and quit + Start in batch mode, parse specified mms files + --disable-smtp + --disable-mms-delivery --delete Ask mmsd to delete the converted MMS --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 /!\ + --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 +``` + +### Using 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 + ``` \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0139b4f --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +# mms2mail + +## To Do + +* Convert all previously received mms on start + +* 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... \ No newline at end of file diff --git a/mms2mail b/mms2mail index dc5e1bd..3631315 100755 --- a/mms2mail +++ b/mms2mail @@ -17,46 +17,67 @@ from gi.repository import GLib import dbus import dbus.mainloop.glib +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 - Mail support is provided by marrow.mailer """ - def __init__(self, delete=False, force_read=False, + 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 disable_dbus: Disable sending dbus commands to mmsd (batch mode) - :type disable_dbus: 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 - 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}") + 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 @@ -178,7 +199,8 @@ class MMS2Mail: 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) + '\n' + 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)) @@ -246,6 +268,103 @@ class MMS2Mail: self.dbus.delete_mms(dbus_path) +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) + 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, attachments, smil) + except dbus.exceptions.DBusException as e: + log.error(e) + return '421 mmsd service not available' + return '250 OK' + + class DbusMMSd(): """Use DBus communication with mmsd.""" @@ -297,6 +416,69 @@ class DbusMMSd(): 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 = dbus.Interface(self.bus.get_object('org.ofono.mms', + '/org/ofono/mms'), + 'org.ofono.mms.Manager') + services = manager.GetServices() + path = services[0][0] + service = dbus.Interface(self.bus.get_object('org.ofono.mms', path), + 'org.ofono.mms.Service') + return service + + 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 send_mms(self, recipients, attachments, 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() + + mms_recipients = dbus.Array([], signature=dbus.Signature('s')) + for r in recipients: + mms_recipients.append(dbus.String(r)) + + if smil: + log.debug("Send MMS as Related") + mms_smil = dbus.String(smil) + else: + log.debug("Send MMS as Mixed") + mms_smil = "" + + mms_attachments = dbus.Array([], signature=dbus.Signature('(sss)')) + for a in attachments: + log.debug("Attachment: ({})".format(a)) + mms_attachments.append(dbus.Struct((dbus.String(a[0]), + dbus.String(a[1]), + dbus.String(a[2]) + ), signature=None)) + + path = service.SendMessage(mms_recipients, mms_smil, mms_attachments) + log.debug(path) + def add_signal_receiver(self): """Add a signal receiver to the current bus.""" if self.mms2mail: @@ -326,10 +508,15 @@ def main(): parser = argparse.ArgumentParser() mode = parser.add_mutually_exclusive_group() mode.add_argument("-d", "--daemon", - help="Use dbus signal from mmsd to trigger conversion", - action='store_true', dest='watcher') + help="start in daemon mode ", + action='store_true', dest='daemon') mode.add_argument("-f", "--file", nargs='+', - help="Parse specified mms files and quit", dest='files') + 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="Ask mmsd to delete the converted MMS") parser.add_argument('--force-read', action='store_true', @@ -356,8 +543,20 @@ def main(): ch.setFormatter(formatter) log.addHandler(ch) + c = Config() d = DbusMMSd() - m = MMS2Mail(delete=args.delete, force_read=args.force_read, + + 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) @@ -365,13 +564,21 @@ def main(): for mms_file in args.files: m.convert(path=mms_file) return - elif args.watcher: + elif args.daemon: log.info("Starting mms2mail in daemon mode") - d.set_mms2mail(m) - d.add_signal_receiver() + 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() else: parser.print_help() + return + d.run() + controller.stop() if __name__ == '__main__': diff --git a/mms2mail.ini b/mms2mail.ini index 4ec222e..5f0f75a 100644 --- a/mms2mail.ini +++ b/mms2mail.ini @@ -3,3 +3,7 @@ mailbox = /var/mail/mobian account = mobian domain = mobian.lan attach_mms = false + +[smtp] +hostname = localhost +port = 2525