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