You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

598 lines
21KB

  1. #!/usr/bin/python3
  2. """An mms to mail converter for mmsd."""
  3. import argparse
  4. import configparser
  5. import getpass
  6. import socket
  7. import mimetypes
  8. import time
  9. import logging
  10. from pathlib import Path
  11. import mailbox
  12. import email
  13. from gi.repository import GLib
  14. from pydbus import SessionBus
  15. from datetime import datetime
  16. import os
  17. import re
  18. import tempfile
  19. from aiosmtpd.controller import Controller
  20. from email import parser
  21. log = logging.getLogger(__name__)
  22. class Config:
  23. """Allow sharing configuration between classes."""
  24. def __init__(self):
  25. """Return the config instance."""
  26. self.config = configparser.ConfigParser()
  27. self.config.read(f"{Path.home()}/.mms/modemmanager/mms2mail.ini")
  28. def get_config(self):
  29. """Return the config element.
  30. :rtype ConfigParser
  31. :return The parsed configuration
  32. """
  33. return self.config
  34. class MMS2Mail:
  35. """
  36. The class handling the conversion between MMS and mail format.
  37. MMS support is provided by python-messaging
  38. """
  39. def __init__(self, config, force_read=False,
  40. force_unlock=False):
  41. """
  42. Return class instance.
  43. :param config: The module configuration file
  44. :type config: ConfigParser
  45. :param force_read: force converting an already read MMS (batch mode)
  46. :type force_read: bool
  47. :param force_unlock: Force mbox unlocking after a few minutes
  48. :type force_unlock: bool
  49. """
  50. self.force_read = force_read
  51. self.force_unlock = force_unlock
  52. cfg = config.get_config()
  53. self.attach_mms = cfg.getboolean('mail', 'attach_mms',
  54. fallback=False)
  55. self.delete = cfg.getboolean('mail', 'delete_from_mmsd',
  56. fallback=False)
  57. self.domain = cfg.get('mail', 'domain',
  58. fallback=socket.getfqdn())
  59. self.user = cfg.get('mail', 'user', fallback=getpass.getuser())
  60. mbox_file = cfg.get('mail', 'mailbox',
  61. fallback=f"/var/mail/{self.user}")
  62. self.mailbox = mailbox.mbox(mbox_file)
  63. self.dbus = None
  64. def set_dbus(self, dbusmmsd):
  65. """
  66. Return the DBus SessionBus.
  67. :param dbusmmsd: The DBus MMSd abstraction class
  68. :type dbusmmsd: DbusMMSd()
  69. """
  70. self.dbus = dbusmmsd
  71. def check_mms(self, path, properties):
  72. """
  73. Check wether the provided file would be converted.
  74. :param path: the mms filesystem path
  75. :type path: str
  76. :param properties: the mms properties
  77. :type properties: Array
  78. :return the mms status or None
  79. :rtype str
  80. """
  81. # Check for mmsd data file
  82. if not Path(f"{path}").is_file():
  83. log.error("MMS file not found : aborting")
  84. return None
  85. # Check for mmsd status file
  86. status = configparser.ConfigParser()
  87. if not Path(f"{path}.status").is_file():
  88. log.error("MMS status file not found : aborting")
  89. return None
  90. status.read_file(open(f"{path}.status"))
  91. if not (self.force_read or not status.getboolean('info', 'read')):
  92. log.error("Already converted MMS : aborting")
  93. return None
  94. return status['info']['state']
  95. def message_added(self, name, value):
  96. """Trigger conversion on MessageAdded signal."""
  97. if value['Status'] == 'downloaded' or value['Status'] == 'received':
  98. log.debug(f"New incoming MMS found ({name.split('/')[-1]})")
  99. self.convert(path=value['Attachments'][0][2], dbus_path=name,
  100. properties=value)
  101. else:
  102. log.debug(f"New outgoing MMS found ({name.split('/')[-1]})")
  103. def convert(self, path, dbus_path, properties):
  104. """
  105. Convert a provided mms file to a mail stored in a mbox.
  106. :param path: the mms filesystem path
  107. :type path: str
  108. :param dbus_path: the mms dbus path
  109. :type dbus_path: str
  110. :param properties: the mms properties
  111. :type properties: Array
  112. """
  113. # Check if the provided file present
  114. status = self.check_mms(path, properties)
  115. if not status:
  116. log.error("MMS file not convertible.")
  117. return
  118. message = email.message.EmailMessage()
  119. # Generate Mail Headers
  120. mms_from = properties.get('Sender', "unknown")
  121. log.debug(f"MMS[From]: {mms_from}")
  122. if '@' in mms_from:
  123. message['From'] = mms_from
  124. else:
  125. message['From'] = f"{mms_from}@{self.domain}"
  126. to = properties.get('Modem Number', None)
  127. if to:
  128. message['To'] = f"{mms_from}@{self.domain}"
  129. recipients = ""
  130. for r in properties['Recipients']:
  131. if to and to in r:
  132. continue
  133. log.debug(f'MMS[CC] : {r}')
  134. if '@' in r:
  135. recipients += f"{r},"
  136. else:
  137. recipients += f"{r}@{self.domain},"
  138. if recipients:
  139. recipients = recipients[:-1]
  140. if to:
  141. message['CC'] = recipients
  142. else:
  143. message['To'] = recipients
  144. message['Subject'] = properties.get('Subject',
  145. f"MMS from {mms_from}")
  146. mms_date = properties.get('Date')
  147. if mms_date:
  148. mms_datetime = datetime.strptime(mms_date, '%Y-%m-%dT%H:%M:%S%z')
  149. mail_date = email.utils.format_datetime(mms_datetime)
  150. message['Date'] = mail_date or email.utils.formatdate()
  151. message.preamble = "This mail is converted from a MMS."
  152. body = ""
  153. attachments = []
  154. for attachment in properties['Attachments']:
  155. cid = attachment[0]
  156. mimetype = attachment[1]
  157. contentfile = attachment[2]
  158. offset = attachment[3]
  159. size = attachment[4]
  160. with open(contentfile, 'rb') as f:
  161. f.seek(offset, 0)
  162. content = f.read(size)
  163. if mimetype is not None:
  164. if 'text/plain' in mimetype:
  165. mimetype, charset = mimetype.split(';', 1)
  166. encoding = charset.split('=')[1]
  167. body += content.decode(encoding,
  168. errors='replace') + '\n'
  169. continue
  170. maintype, subtype = mimetype.split('/', 1)
  171. extension = str(mimetypes.guess_extension(mimetype))
  172. filename = cid
  173. attachments.append([content, maintype,
  174. subtype, filename + extension])
  175. if body:
  176. message.set_content(body)
  177. for a in attachments:
  178. message.add_attachment(a[0],
  179. maintype=a[1],
  180. subtype=a[2],
  181. filename=a[3])
  182. # Add MMS binary file, for debugging purpose
  183. # or reparsing in the future
  184. if self.attach_mms:
  185. with open(path, 'rb') as fp:
  186. message.add_attachment(fp.read(),
  187. maintype='application',
  188. subtype='octet-stream',
  189. filename=path.split('/')[-1])
  190. # Write the mail in case of mbox lock retry for 5 minutes
  191. # Ultimately write in an mbox in the home folder
  192. end_time = time.time() + (5 * 60)
  193. while True:
  194. try:
  195. # self.mailer.send(message)
  196. self.mailbox.lock()
  197. self.mailbox.add(mailbox.mboxMessage(message))
  198. self.mailbox.flush()
  199. self.mailbox.unlock()
  200. break
  201. except (mailbox.ExternalClashError, FileExistsError) as e:
  202. log.warn(f"Exception Mbox lock : {e}")
  203. if time.time() > end_time:
  204. if self.force_unlock:
  205. log.error("Force removing lock")
  206. self.mailbox.unlock()
  207. else:
  208. fs_mbox_path = f"{Path.home()}/.mms/failsafembox"
  209. fs_mbox = mailbox.mbox(fs_mbox_path)
  210. log.warning(f"Writing in internal mbox {fs_mbox_path}")
  211. try:
  212. fs_mbox.unlock()
  213. fs_mbox.lock()
  214. fs_mbox.add(mailbox.mboxMessage(str(message)))
  215. fs_mbox.flush()
  216. fs_mbox.unlock()
  217. break
  218. except (mailbox.ExternalClashError,
  219. FileExistsError) as e:
  220. log.error(f"Failsafe Mbox error : {e}")
  221. log.error(f"MMS cannot be written to any mbox : \
  222. {path.split('/')[-1]}")
  223. finally:
  224. break
  225. else:
  226. time.sleep(5)
  227. # Ask mmsd to mark message as read and delete it
  228. if properties:
  229. self.dbus.mark_mms_read(dbus_path)
  230. if self.delete:
  231. self.dbus.delete_mms(dbus_path)
  232. def convert_stored_mms(self):
  233. """Convert all mms from mmsd storage."""
  234. log.info('INIT : Converting MMs from storage')
  235. messages = self.dbus.get_messages()
  236. for m in messages:
  237. self.message_added(name=m[0], value=m[1])
  238. class Mail2MMSHandler:
  239. """The class handling the conversion between mail and MMS format."""
  240. def __init__(self, dbusmmsd):
  241. """
  242. Return the Mail2MMS instance.
  243. :param dbusmmsd: The DBus MMSd abstraction class
  244. :type dbusmmsd: DbusMMSd()
  245. :param config: The module configuration file
  246. :type config: ConfigParser
  247. """
  248. self.parser = parser.BytesParser()
  249. self.pattern = re.compile('^\+[0-9]+$')
  250. self.dbusmmsd = dbusmmsd
  251. mmsd_config = dbusmmsd.get_manager_config()
  252. self.auto_create_smil = mmsd_config.get('AutoCreateSMIL', False)
  253. self.max_attachments = mmsd_config.get('MaxAttachments', 25)
  254. self.total_max_attachment_size = mmsd_config.get(
  255. 'TotalMaxAttachmentSize',
  256. 1100000)
  257. self.use_delivery_reports = mmsd_config.get('UseDeliveryReports',
  258. False)
  259. async def handle_DATA(self, server, session, envelope):
  260. """
  261. Handle the reception of a new mail via smtp.
  262. :param server: The SMTP server instance
  263. :type server: SMTP
  264. :param session: The session instance currently being handled
  265. :type session: Session
  266. :param envelope: The envelope instance of the current SMTP Transaction
  267. :type envelope: Envelope
  268. """
  269. recipients = []
  270. attachments = []
  271. smil = None
  272. for r in envelope.rcpt_tos:
  273. number = r.split('@')[0]
  274. if self.pattern.search(number):
  275. log.debug(f'Add recipient number : {number}')
  276. recipients.append(number)
  277. else:
  278. log.debug(f'Ignoring recipient : {r}')
  279. if len(recipients) == 0:
  280. log.info('No sms recipient')
  281. return '553 Requested action not taken: mailbox name not allowed'
  282. mail = self.parser.parsebytes(envelope.content)
  283. subject = mail.get('subject', failobj=None)
  284. cid = 1
  285. total_size = 0
  286. with tempfile.TemporaryDirectory(prefix='mailtomms-') as tmp_dir:
  287. for part in mail.walk():
  288. content_type = part.get_content_type()
  289. if 'multipart' in content_type:
  290. continue
  291. filename = part.get_filename()
  292. if not filename:
  293. ext = mimetypes.guess_extension(part.get_content_type())
  294. if not ext:
  295. # Use a generic bag-of-bits extension
  296. ext = '.bin'
  297. filename = f'part-{cid:03d}{ext}'
  298. if filename == 'smil.xml':
  299. smil = part.get_payload(decode=True)
  300. continue
  301. path = os.path.join(tmp_dir, filename)
  302. if content_type == 'text/plain':
  303. with open(path, 'wt', encoding='utf-8') as af:
  304. charset = part.get_content_charset(failobj='utf-8')
  305. total_size += af.write(part.
  306. get_payload(decode=True).
  307. decode(charset))
  308. else:
  309. with open(path, 'wb') as af:
  310. total_size += af.write(part.
  311. get_payload(decode=True))
  312. attachments.append((f"cid-{cid}", content_type, path))
  313. cid += 1
  314. if len(attachments) == 0:
  315. return '550 No attachments found'
  316. elif len(attachments) > self.max_attachments:
  317. return '550 Too much attachments'
  318. elif total_size > self.total_max_attachment_size:
  319. return '554 5.3.4 Message too big for system'
  320. try:
  321. self.dbusmmsd.send_mms(recipients=recipients,
  322. attachments=attachments,
  323. subject=subject,
  324. smil=smil)
  325. except Exception as e:
  326. log.error(e)
  327. return '421 mmsd service not available'
  328. return '250 OK'
  329. class DbusMMSd():
  330. """Use DBus communication with mmsd."""
  331. def __init__(self, mms2mail=None):
  332. """
  333. Return a DBusWatcher instance.
  334. :param mms2mail: An mms2mail instance to convert new mms
  335. :type mms2mail: mms2mail()
  336. """
  337. self.mms2mail = mms2mail
  338. self.bus = SessionBus()
  339. def set_mms2mail(self, mms2mail):
  340. """
  341. Set mms2mail instance handling dbus event.
  342. :param mms2mail: An mms2mail instance to convert new mms
  343. :type mms2mail: mms2mail()
  344. """
  345. self.mms2mail = mms2mail
  346. def mark_mms_read(self, dbus_path):
  347. """
  348. Ask mmsd to mark the mms as read.
  349. :param dbus_path: the mms dbus path
  350. :type dbus_path: str
  351. """
  352. message = self.bus.get('org.ofono.mms', dbus_path)
  353. log.debug(f"Marking MMS as read {dbus_path}")
  354. message.MarkRead()
  355. def delete_mms(self, dbus_path):
  356. """
  357. Ask mmsd to delete the mms.
  358. :param dbus_path: the mms dbus path
  359. :type dbus_path: str
  360. """
  361. message = self.bus.get('org.ofono.mms', dbus_path)
  362. log.debug(f"Deleting MMS {dbus_path}")
  363. message.Delete()
  364. def get_service(self):
  365. """
  366. Get mmsd Service Interface.
  367. :return the mmsd service
  368. :rtype dbus.Interface
  369. """
  370. manager = self.bus.get('org.ofono.mms', '/org/ofono/mms')
  371. services = manager.GetServices()
  372. path = services[0][0]
  373. service = self.bus.get('org.ofono.mms', path)
  374. return service
  375. def get_messages(self):
  376. """
  377. Ask mmsd all stored mms.
  378. :return all mms from mmsd storage
  379. :rtype Array
  380. """
  381. service = self.get_service()
  382. return service.GetMessages()
  383. def get_manager_config(self):
  384. """
  385. Ask mmsd its properties.
  386. :return the mmsd manager service properties
  387. :rtype dict
  388. """
  389. service = self.get_service()
  390. return service.GetProperties()
  391. def get_send_message_version(self):
  392. """
  393. Ask mmsd its SendMessage method Signature.
  394. :return true if mmsd is mmsd-tng allowing Subject in mms
  395. :rtype bool
  396. """
  397. if not hasattr(self, 'mmsdtng'):
  398. from xml.dom import minidom
  399. mmsdtng = False
  400. svc = self.get_service()
  401. i = svc.Introspect()
  402. dom = minidom.parseString(i)
  403. for method in dom.getElementsByTagName('method'):
  404. if method.getAttribute('name') == "SendMessage":
  405. for arg in method.getElementsByTagName('arg'):
  406. if arg.getAttribute('name') == 'options':
  407. mmsdtng = True
  408. self.mmsdtng = mmsdtng
  409. return self.mmsdtng
  410. def send_mms(self, recipients, attachments, subject=None, smil=None):
  411. """
  412. Ask mmsd to send a MMS.
  413. :param recipients: The mms recipients phone numbers
  414. :type recipients: Array(str)
  415. :param attachments: The mms attachments [name, mime type, filepath]
  416. :type attachments: Array(str,str,str)
  417. :param smil: The Smil.xml content allowing MMS customization
  418. :type smil: str
  419. """
  420. service = self.get_service()
  421. mmsdtng = self.get_send_message_version()
  422. if mmsdtng:
  423. log.debug("Using mmsd-tng as backend")
  424. option_list = {}
  425. if subject:
  426. log.debug(f"MMS Subject = {subject}")
  427. option_list['Subject'] = GLib.Variant('s', subject)
  428. if smil:
  429. log.debug("Send MMS as Related")
  430. option_list['smil'] = GLib.Variant('s', smil)
  431. options = GLib.Variant('a{sv}', option_list)
  432. path = service.SendMessage(recipients, options,
  433. attachments)
  434. else:
  435. log.debug("Using mmsd as backend")
  436. if smil:
  437. log.debug("Send MMS as Related")
  438. else:
  439. log.debug("Send MMS as Mixed")
  440. smil = ""
  441. path = service.SendMessage(recipients, smil,
  442. attachments)
  443. log.debug(path)
  444. def add_signal_receiver(self):
  445. """Add a signal receiver to the current bus."""
  446. if self.mms2mail:
  447. service = self.get_service()
  448. service.onMessageAdded = self.mms2mail.message_added
  449. return True
  450. else:
  451. return False
  452. def run(self):
  453. """Run the dbus mainloop."""
  454. mainloop = GLib.MainLoop()
  455. log.info("Starting DBus watcher mainloop")
  456. try:
  457. mainloop.run()
  458. except KeyboardInterrupt:
  459. log.info("Stopping DBus watcher mainloop")
  460. mainloop.quit()
  461. def main():
  462. """Run the different functions handling mms and mail."""
  463. parser = argparse.ArgumentParser()
  464. parser.add_argument('--disable-smtp', action='store_true',
  465. dest='disable_smtp')
  466. parser.add_argument('--disable-mms-delivery', action='store_true',
  467. dest='disable_mms_delivery')
  468. parser.add_argument('--force-read', action='store_true',
  469. dest='force_read', help="Force conversion even if MMS \
  470. is marked as read")
  471. parser.add_argument('--force-unlock', action='store_true',
  472. dest='force_unlock', help="BEWARE COULD LEAD TO \
  473. WHOLE MBOX CORRUPTION \
  474. Force unlocking the mbox \
  475. after a few minutes /!\\")
  476. parser.add_argument('-l', '--logging', dest='log_level', default='warning',
  477. choices=['critical', 'error', 'warning',
  478. 'info', 'debug'],
  479. help='Define the logger output level'
  480. )
  481. args = parser.parse_args()
  482. log.setLevel(args.log_level.upper())
  483. ch = logging.StreamHandler()
  484. ch.setLevel(logging.DEBUG)
  485. log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  486. formatter = logging.Formatter(log_format)
  487. ch.setFormatter(formatter)
  488. log.addHandler(ch)
  489. c = Config()
  490. d = DbusMMSd()
  491. h = Mail2MMSHandler(dbusmmsd=d)
  492. controller = Controller(h,
  493. hostname=c.get_config().get('smtp', 'hostname',
  494. fallback='localhost'),
  495. port=c.get_config().get('smtp', 'port',
  496. fallback=2525))
  497. m = MMS2Mail(config=c,
  498. force_read=args.force_read,
  499. force_unlock=args.force_unlock)
  500. m.set_dbus(d)
  501. log.info("Starting mms2mail")
  502. if not args.disable_smtp:
  503. log.info("Activating smtp to mmsd server")
  504. controller.start()
  505. if not args.disable_mms_delivery:
  506. log.info("Activating mms to mbox server")
  507. d.set_mms2mail(m)
  508. d.add_signal_receiver()
  509. m.convert_stored_mms()
  510. d.run()
  511. controller.stop()
  512. if __name__ == '__main__':
  513. main()