1# -*- coding: utf-8 -*- 2 3# Copyright(C) 2009-2011 Romain Bignon, Christophe Benz 4# 5# This file is part of weboob. 6# 7# weboob is free software: you can redistribute it and/or modify 8# it under the terms of the GNU Lesser General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# weboob is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU Lesser General Public License for more details. 16# 17# You should have received a copy of the GNU Lesser General Public License 18# along with weboob. If not, see <http://www.gnu.org/licenses/>. 19 20from __future__ import print_function 21 22from email.mime.text import MIMEText 23from smtplib import SMTP 24from email.header import Header, decode_header 25from email.utils import parseaddr, formataddr, formatdate 26from email import message_from_file, message_from_string 27from smtpd import SMTPServer 28import time 29import re 30import logging 31import asyncore 32import subprocess 33import socket 34 35from weboob.core import Weboob, CallErrors 36from weboob.core.scheduler import Scheduler 37from weboob.capabilities.messages import CapMessages, CapMessagesPost, Thread, Message 38from weboob.tools.application.repl import ReplApplication 39from weboob.tools.compat import unicode 40from weboob.tools.date import utc2local 41from weboob.tools.html import html2text 42from weboob.tools.misc import get_backtrace, to_unicode 43 44 45__all__ = ['Monboob'] 46 47 48class FakeSMTPD(SMTPServer): 49 def __init__(self, app, bindaddr, port): 50 SMTPServer.__init__(self, (bindaddr, port), None) 51 self.app = app 52 53 def process_message(self, peer, mailfrom, rcpttos, data): 54 msg = message_from_string(data) 55 self.app.process_incoming_mail(msg) 56 57 58class MonboobScheduler(Scheduler): 59 def __init__(self, app): 60 super(MonboobScheduler, self).__init__() 61 self.app = app 62 63 def run(self): 64 if self.app.options.smtpd: 65 if ':' in self.app.options.smtpd: 66 host, port = self.app.options.smtpd.split(':', 1) 67 else: 68 host = '127.0.0.1' 69 port = self.app.options.smtpd 70 try: 71 FakeSMTPD(self.app, host, int(port)) 72 except socket.error as e: 73 self.logger.error('Unable to start the SMTP daemon: %s' % e) 74 return False 75 76 # XXX Fuck, we shouldn't copy this piece of code from 77 # weboob.scheduler.Scheduler.run(). 78 try: 79 while True: 80 self.stop_event.wait(0.1) 81 if self.app.options.smtpd: 82 asyncore.loop(timeout=0.1, count=1) 83 except KeyboardInterrupt: 84 self._wait_to_stop() 85 raise 86 else: 87 self._wait_to_stop() 88 return True 89 90 91class Monboob(ReplApplication): 92 APPNAME = 'monboob' 93 VERSION = '2.0' 94 COPYRIGHT = 'Copyright(C) 2010-YEAR Romain Bignon' 95 DESCRIPTION = 'Daemon allowing to regularly check for new messages on various websites, ' \ 96 'and send an email for each message, and post a reply to a message on a website.' 97 SHORT_DESCRIPTION = "daemon to send and check messages" 98 CONFIG = {'interval': 300, 99 'domain': 'weboob.example.org', 100 'recipient': 'weboob@example.org', 101 'smtp': 'localhost', 102 'pipe': '', 103 'html': 0} 104 CAPS = CapMessages 105 DISABLE_REPL = True 106 107 def add_application_options(self, group): 108 group.add_option('-S', '--smtpd', help='run a fake smtpd server and set the port') 109 110 def create_weboob(self): 111 return Weboob(scheduler=MonboobScheduler(self)) 112 113 def load_default_backends(self): 114 self.load_backends(CapMessages, storage=self.create_storage()) 115 116 def main(self, argv): 117 self.load_config() 118 try: 119 self.config.set('interval', int(self.config.get('interval'))) 120 if self.config.get('interval') < 1: 121 raise ValueError() 122 except ValueError: 123 print('Configuration error: interval must be an integer >0.', file=self.stderr) 124 return 1 125 126 try: 127 self.config.set('html', int(self.config.get('html'))) 128 if self.config.get('html') not in (0, 1): 129 raise ValueError() 130 except ValueError: 131 print('Configuration error: html must be 0 or 1.', file=self.stderr) 132 return 2 133 134 return ReplApplication.main(self, argv) 135 136 def get_email_address_ident(self, msg, header): 137 s = msg.get(header) 138 if not s: 139 return None 140 m = re.match('.*<([^@]*)@(.*)>', s) 141 if m: 142 return m.group(1) 143 else: 144 try: 145 return s.split('@')[0] 146 except IndexError: 147 return s 148 149 def do_post(self, line): 150 """ 151 post 152 153 Pipe with a mail to post message. 154 """ 155 msg = message_from_file(self.stdin) 156 return self.process_incoming_mail(msg) 157 158 def process_incoming_mail(self, msg): 159 to = self.get_email_address_ident(msg, 'To') 160 sender = msg.get('From') 161 reply_to = self.get_email_address_ident(msg, 'In-Reply-To') 162 163 title = msg.get('Subject') 164 if title: 165 new_title = u'' 166 for part in decode_header(title): 167 if part[1]: 168 new_title += unicode(part[0], part[1]) 169 else: 170 new_title += unicode(part[0]) 171 title = new_title 172 173 content = u'' 174 for part in msg.walk(): 175 if part.get_content_type() == 'text/plain': 176 s = part.get_payload(decode=True) 177 charsets = part.get_charsets() + msg.get_charsets() 178 for charset in charsets: 179 try: 180 if charset is not None: 181 content += unicode(s, charset) 182 else: 183 content += unicode(s) 184 except UnicodeError as e: 185 self.logger.warning('Unicode error: %s' % e) 186 continue 187 except Exception as e: 188 self.logger.exception(e) 189 continue 190 else: 191 break 192 193 if len(content) == 0: 194 print('Unable to send an empty message', file=self.stderr) 195 return 1 196 197 # remove signature 198 content = content.split(u'\n-- \n')[0] 199 200 parent_id = None 201 if reply_to is None: 202 # This is a new message 203 if '.' in to: 204 backend_name, thread_id = to.split('.', 1) 205 else: 206 backend_name = to 207 thread_id = None 208 else: 209 # This is a reply 210 try: 211 backend_name, id = reply_to.split('.', 1) 212 thread_id, parent_id = id.rsplit('.', 1) 213 except ValueError: 214 print('In-Reply-To header might be in form <backend.thread_id.message_id>', file=self.stderr) 215 return 1 216 217 # Default use the To header field to know the backend to use. 218 if to and backend_name != to: 219 backend_name = to 220 221 try: 222 backend = self.weboob.backend_instances[backend_name] 223 except KeyError: 224 print('Backend %s not found' % backend_name, file=self.stderr) 225 return 1 226 227 if not backend.has_caps(CapMessagesPost): 228 print('The backend %s does not implement CapMessagesPost' % backend_name, file=self.stderr) 229 return 1 230 231 thread = Thread(thread_id) 232 message = Message(thread, 233 0, 234 title=title, 235 sender=sender, 236 receivers=[to], 237 parent=Message(thread, parent_id) if parent_id else None, 238 content=content) 239 try: 240 backend.post_message(message) 241 except Exception as e: 242 content = u'Unable to send message to %s:\n' % thread_id 243 content += u'\n\t%s\n' % to_unicode(e) 244 if logging.root.level <= logging.DEBUG: 245 content += u'\n%s\n' % to_unicode(get_backtrace(e)) 246 self.send_email(backend.name, Message(thread, 247 0, 248 title='Unable to send message', 249 sender='Monboob', 250 parent=Message(thread, parent_id) if parent_id else None, 251 content=content)) 252 253 def do_run(self, line): 254 """ 255 run 256 257 Run the fetching daemon. 258 """ 259 self.weboob.repeat(self.config.get('interval'), self.process) 260 self.weboob.loop() 261 262 def do_once(self, line): 263 """ 264 once 265 266 Send mails only once, then exit. 267 """ 268 return self.process() 269 270 def process(self): 271 try: 272 for message in self.weboob.do('iter_unread_messages'): 273 if self.send_email(message.backend, message): 274 self.weboob[message.backend].set_message_read(message) 275 except CallErrors as e: 276 self.bcall_errors_handler(e) 277 278 def send_email(self, backend_name, mail): 279 domain = self.config.get('domain') 280 recipient = self.config.get('recipient') 281 282 parent_message = mail.parent 283 references = [] 284 while parent_message: 285 references.append(u'<%s.%s@%s>' % (backend_name, mail.parent.full_id, domain)) 286 parent_message = parent_message.parent 287 subject = mail.title 288 sender = u'"%s" <%s@%s>' % (mail.sender.replace('"', '""') if mail.sender else '', 289 backend_name, domain) 290 291 # assume that .date is an UTC datetime 292 date = formatdate(time.mktime(utc2local(mail.date).timetuple()), localtime=True) 293 msg_id = u'<%s.%s@%s>' % (backend_name, mail.full_id, domain) 294 295 if self.config.get('html') and mail.flags & mail.IS_HTML: 296 body = mail.content 297 content_type = 'html' 298 else: 299 if mail.flags & mail.IS_HTML: 300 body = html2text(mail.content) 301 else: 302 body = mail.content 303 content_type = 'plain' 304 305 if body is None: 306 body = '' 307 308 if mail.signature: 309 if self.config.get('html') and mail.flags & mail.IS_HTML: 310 body += u'<p>-- <br />%s</p>' % mail.signature 311 else: 312 body += u'\n\n-- \n' 313 if mail.flags & mail.IS_HTML: 314 body += html2text(mail.signature) 315 else: 316 body += mail.signature 317 318 # Header class is smart enough to try US-ASCII, then the charset we 319 # provide, then fall back to UTF-8. 320 header_charset = 'ISO-8859-1' 321 322 # We must choose the body charset manually 323 for body_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8': 324 try: 325 body.encode(body_charset) 326 except UnicodeError: 327 pass 328 else: 329 break 330 331 # Split real name (which is optional) and email address parts 332 sender_name, sender_addr = parseaddr(sender) 333 recipient_name, recipient_addr = parseaddr(recipient) 334 335 # We must always pass Unicode strings to Header, otherwise it will 336 # use RFC 2047 encoding even on plain ASCII strings. 337 sender_name = str(Header(unicode(sender_name), header_charset)) 338 recipient_name = str(Header(unicode(recipient_name), header_charset)) 339 340 # Make sure email addresses do not contain non-ASCII characters 341 sender_addr = sender_addr.encode('ascii') 342 recipient_addr = recipient_addr.encode('ascii') 343 344 # Create the message ('plain' stands for Content-Type: text/plain) 345 msg = MIMEText(body.encode(body_charset), content_type, body_charset) 346 msg['From'] = formataddr((sender_name, sender_addr)) 347 msg['To'] = formataddr((recipient_name, recipient_addr)) 348 msg['Subject'] = Header(unicode(subject), header_charset) 349 msg['Message-Id'] = msg_id 350 msg['Date'] = date 351 if references: 352 msg['In-Reply-To'] = references[0] 353 msg['References'] = u" ".join(reversed(references)) 354 355 self.logger.info('Send mail from <%s> to <%s>' % (sender, recipient)) 356 if len(self.config.get('pipe')) > 0: 357 p = subprocess.Popen(self.config.get('pipe'), 358 shell=True, 359 stdin=subprocess.PIPE, 360 stdout=subprocess.PIPE, 361 stderr=subprocess.STDOUT) 362 p.stdin.write(msg.as_string()) 363 p.stdin.close() 364 if p.wait() != 0: 365 self.logger.error('Unable to deliver mail: %s' % p.stdout.read().strip()) 366 return False 367 else: 368 # Send the message via SMTP to localhost:25 369 try: 370 smtp = SMTP(self.config.get('smtp')) 371 smtp.sendmail(sender, recipient, msg.as_string()) 372 except Exception as e: 373 self.logger.error('Unable to deliver mail: %s' % e) 374 return False 375 else: 376 smtp.quit() 377 378 return True 379