[feat] SMTP server to send MMS
This commit is contained in:
parent
06a5e82867
commit
cc30d69912
4 changed files with 278 additions and 35 deletions
47
README.md
47
README.md
|
@ -1,24 +1,29 @@
|
||||||
# mms2mail
|
# 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
|
mms2mail:
|
||||||
sent by mmsd.
|
* convert incoming mms from mmsd to mail and store it in unix mbox.
|
||||||
By default it store them in the current user mbox (```/var/mail/$USER```)
|
* provide a smtp server converting mail to mms with mmsd.
|
||||||
in case the mbox is locked by another process the output could be found in :
|
|
||||||
```$HOME/.mms/failsafembox```
|
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
|
||||||
- python-messaging (pip install python-messaging)
|
- python-messaging (pip install python-messaging)
|
||||||
|
|
||||||
### setup
|
### 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
|
||||||
|
sudo apt-get install python3-aiosmtpd
|
||||||
pip install --user python-messaging
|
pip install --user python-messaging
|
||||||
|
|
||||||
mkdir -p ~/.local/bin
|
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
|
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
|
||||||
|
|
||||||
|
[smtp]
|
||||||
|
hostname = localhost
|
||||||
|
port = 2525
|
||||||
```
|
```
|
||||||
|
|
||||||
## usage
|
## usage
|
||||||
|
|
||||||
|
### reference
|
||||||
```
|
```
|
||||||
mms2mail [-h] [-d | -f FILES [FILES ...]] [--delete] [--force-read]
|
mms2mail [-h] [-d | -f FILES [FILES ...]] [--disable-smtp] [--disable-mms-delivery] [--delete] [--force-read] [--force-unlock] [-l {critical,error,warning,info,debug}]
|
||||||
[--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, --daemon Use dbus signal from mmsd to trigger conversion
|
-d, --daemon start in daemon mode
|
||||||
-f FILES [FILES ...], --file FILES [FILES ...]
|
-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
|
--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
|
--force-unlock BEWARE COULD LEAD TO WHOLE MBOX CORRUPTION Force unlocking the mbox after a few minutes /!\
|
||||||
mbox after a few minutes /!\
|
|
||||||
-l {critical,error,warning,info,debug}, --logging {critical,error,warning,info,debug}
|
-l {critical,error,warning,info,debug}, --logging {critical,error,warning,info,debug}
|
||||||
Define the logger output level
|
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
|
||||||
|
|
||||||
|
```
|
11
TODO.md
Normal file
11
TODO.md
Normal file
|
@ -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...
|
241
mms2mail
241
mms2mail
|
@ -17,45 +17,66 @@ from gi.repository import GLib
|
||||||
import dbus
|
import dbus
|
||||||
import dbus.mainloop.glib
|
import dbus.mainloop.glib
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from aiosmtpd.controller import Controller
|
||||||
|
from email import parser
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
||||||
"""
|
"""
|
||||||
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, delete=False, force_read=False,
|
||||||
force_unlock=False):
|
force_unlock=False):
|
||||||
"""
|
"""
|
||||||
Return class instance.
|
Return class instance.
|
||||||
|
|
||||||
|
:param config: The module configuration file
|
||||||
|
:type config: ConfigParser
|
||||||
|
|
||||||
:param delete: delete MMS after conversion
|
:param delete: delete MMS after conversion
|
||||||
:type delete: bool
|
: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 disable_dbus: Disable sending dbus commands to mmsd (batch mode)
|
|
||||||
:type disable_dbus: bool
|
|
||||||
|
|
||||||
:param force_unlock: Force mbox unlocking after a few minutes
|
:param force_unlock: Force mbox unlocking after a few minutes
|
||||||
:type force_unlock: bool
|
:type force_unlock: bool
|
||||||
"""
|
"""
|
||||||
self.delete = delete
|
self.delete = delete
|
||||||
self.force_read = force_read
|
self.force_read = force_read
|
||||||
self.force_unlock = force_unlock
|
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.domain = self.config.get('mail', 'domain',
|
self.domain = cfg.get('mail', 'domain',
|
||||||
fallback=socket.getfqdn())
|
fallback=socket.getfqdn())
|
||||||
self.user = self.config.get('mail', 'user', fallback=getpass.getuser())
|
self.user = cfg.get('mail', 'user', fallback=getpass.getuser())
|
||||||
mbox_file = self.config.get('mail', 'mailbox',
|
mbox_file = cfg.get('mail', 'mailbox',
|
||||||
fallback=f"/var/mail/{self.user}")
|
fallback=f"/var/mail/{self.user}")
|
||||||
self.mailbox = mailbox.mbox(mbox_file)
|
self.mailbox = mailbox.mbox(mbox_file)
|
||||||
self.dbus = None
|
self.dbus = None
|
||||||
|
@ -178,7 +199,8 @@ class MMS2Mail:
|
||||||
maintype, subtype = datacontent[0].split('/', 1)
|
maintype, subtype = datacontent[0].split('/', 1)
|
||||||
if 'text/plain' in datacontent[0]:
|
if 'text/plain' in datacontent[0]:
|
||||||
encoding = datacontent[1].get('Charset', 'utf-8')
|
encoding = datacontent[1].get('Charset', 'utf-8')
|
||||||
body += data_part.data.decode(encoding) + '\n'
|
body += data_part.data.decode(encoding,
|
||||||
|
errors='replace') + '\n'
|
||||||
continue
|
continue
|
||||||
extension = str(mimetypes.guess_extension(datacontent[0]))
|
extension = str(mimetypes.guess_extension(datacontent[0]))
|
||||||
filename = datacontent[1].get('Name', str(data_id))
|
filename = datacontent[1].get('Name', str(data_id))
|
||||||
|
@ -246,6 +268,103 @@ class MMS2Mail:
|
||||||
self.dbus.delete_mms(dbus_path)
|
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():
|
class DbusMMSd():
|
||||||
"""Use DBus communication with mmsd."""
|
"""Use DBus communication with mmsd."""
|
||||||
|
|
||||||
|
@ -297,6 +416,69 @@ class DbusMMSd():
|
||||||
log.debug(f"Deleting MMS {dbus_path}")
|
log.debug(f"Deleting MMS {dbus_path}")
|
||||||
message.Delete()
|
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):
|
def add_signal_receiver(self):
|
||||||
"""Add a signal receiver to the current bus."""
|
"""Add a signal receiver to the current bus."""
|
||||||
if self.mms2mail:
|
if self.mms2mail:
|
||||||
|
@ -326,10 +508,15 @@ def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
mode = parser.add_mutually_exclusive_group()
|
mode = parser.add_mutually_exclusive_group()
|
||||||
mode.add_argument("-d", "--daemon",
|
mode.add_argument("-d", "--daemon",
|
||||||
help="Use dbus signal from mmsd to trigger conversion",
|
help="start in daemon mode ",
|
||||||
action='store_true', dest='watcher')
|
action='store_true', dest='daemon')
|
||||||
mode.add_argument("-f", "--file", nargs='+',
|
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',
|
parser.add_argument('--delete', action='store_true', dest='delete',
|
||||||
help="Ask mmsd to delete the converted MMS")
|
help="Ask mmsd to delete the converted MMS")
|
||||||
parser.add_argument('--force-read', action='store_true',
|
parser.add_argument('--force-read', action='store_true',
|
||||||
|
@ -356,8 +543,20 @@ def main():
|
||||||
ch.setFormatter(formatter)
|
ch.setFormatter(formatter)
|
||||||
log.addHandler(ch)
|
log.addHandler(ch)
|
||||||
|
|
||||||
|
c = Config()
|
||||||
d = DbusMMSd()
|
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)
|
force_unlock=args.force_unlock)
|
||||||
m.set_dbus(d)
|
m.set_dbus(d)
|
||||||
|
|
||||||
|
@ -365,13 +564,21 @@ def main():
|
||||||
for mms_file in args.files:
|
for mms_file in args.files:
|
||||||
m.convert(path=mms_file)
|
m.convert(path=mms_file)
|
||||||
return
|
return
|
||||||
elif args.watcher:
|
elif args.daemon:
|
||||||
log.info("Starting mms2mail in daemon mode")
|
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.set_mms2mail(m)
|
||||||
d.add_signal_receiver()
|
d.add_signal_receiver()
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
d.run()
|
d.run()
|
||||||
|
controller.stop()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -3,3 +3,7 @@ mailbox = /var/mail/mobian
|
||||||
account = mobian
|
account = mobian
|
||||||
domain = mobian.lan
|
domain = mobian.lan
|
||||||
attach_mms = false
|
attach_mms = false
|
||||||
|
|
||||||
|
[smtp]
|
||||||
|
hostname = localhost
|
||||||
|
port = 2525
|
||||||
|
|
Loading…
Reference in a new issue