1# -*- coding: utf-8 -*-
2#   Copyright 2009-2018 Oli Schacher
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16#
17#
18"""
19Vacation/Autoreply Plugin
20"""
21
22from fuglu.shared import ScannerPlugin, DUNNO
23from fuglu.bounce import Bounce
24from fuglu.extensions.sql import SQL_EXTENSION_ENABLED, get_session
25import time
26import re
27from threading import Lock
28from datetime import datetime, timedelta
29import logging
30import traceback
31from email.mime.text import MIMEText
32from email.header import Header
33
34# from address regex
35vacation_ignoresenderregex = ["^owner-",
36                              "^request-",
37                              "-request@",
38                              # everything with 'bounce' in the lefthandside is
39                              # probably automated
40                              "bounce.*@",
41                              "-confirm@",
42                              "-errors@",
43                              "^no[\-]?reply",
44                              "^donotreply",
45                              "^postmaster@",
46                              "^mailer[-_]daemon@",
47                              "^mailer@",
48                              "^listserv@",
49                              "^majordom[o]?@",
50                              "^mailman@",
51                              "^nobody@",
52                              "^bounce",
53                              "^www(-data)?@",
54                              "^mdaemon@",
55                              "^root@",
56                              "^webmaster@",
57                              "^administrator@",
58                              "^support@",
59                              "^news(letter)?@",
60                              ]
61
62
63# if one of these headers exists, we should not send auto reply
64vacation_ignoreheaderexists = ["list-help",
65                               "list-unsubscribe",
66                               "list-subscribe",
67                               "list-owner",
68                               "list-post",
69                               "list-archive",
70                               "list-id",
71                               "mailing-List",
72                               "x-facebook-notify",
73                               "x-mailing-list",
74                               'x-cron-env',
75                               'x-autoresponse',
76                               'x-eBay-mailtracker'
77                               ]
78# if these headers exist and match regex, we should not send auto reply
79vacation_ignoreheaderregex = {
80    'x-spam-flag': 'yes',
81    'x-spam-status': 'yes',
82    'X-Spam-Flag2': 'yes',
83    'X-Bluewin-Spam-Score': '^100',
84    'precedence': '(bulk|list|junk)',
85    'x-precedence': '(bulk|list|junk)',
86    'x-barracuda-spam-status': 'yes',
87    'x-dspam-result': '(spam|bl[ao]cklisted)',
88    'X-Mailer': '^Mail$',
89    'auto-submitted': 'auto-replied',
90    'X-Auto-Response-Suppress': '(AutoReply|OOF)',
91}
92
93
94class Vacation(object):
95
96    """represents a user defined vacation"""
97
98    def __init__(self):
99        self.enabled = True
100        self.created = None
101        self.start = None
102        self.end = None
103        self.awayuser = None
104        self.subject = None
105        self.body = None
106        self.ignoresender = None
107
108    def __str__(self):
109        return "Vacation(%s) start=%s end=%s ignore=%s" % (self.awayuser, self.start, self.end, self.ignoresender)
110
111    def __repr__(self):
112        return str(self)
113
114
115class VacationReply(object):
116
117    """a reply to a vacation message for logging to the database"""
118
119    def __init__(self):
120        self.sent = None
121        self.recipient = None
122
123
124if SQL_EXTENSION_ENABLED:
125    from sqlalchemy import Table, Column, TEXT, TIMESTAMP, Integer, ForeignKey, Unicode, Boolean
126    from sqlalchemy.ext.declarative import declarative_base
127    from sqlalchemy.orm import mapper, relation
128    DeclarativeBase = declarative_base()
129    metadata = DeclarativeBase.metadata
130
131    vacation_table = Table("vacation", metadata,
132                           Column('id', Integer, primary_key=True),
133                           Column('created', TIMESTAMP, nullable=False),
134                           Column(
135                               'enabled', Boolean, nullable=False, default=True),
136                           Column('start', TIMESTAMP, nullable=False),
137                           Column('end', TIMESTAMP, nullable=False),
138                           Column(
139                               'awayuser', Unicode(255), unique=True, nullable=False),
140                           Column('subject', Unicode(255), nullable=False),
141                           Column('body', TEXT, nullable=False),
142                           Column('ignoresender', TEXT, nullable=False),
143                           )
144
145    vacationreply_table = Table("vacationreply", metadata,
146                                Column('id', Integer, primary_key=True),
147                                Column(
148                                    'vacation_id', None, ForeignKey('vacation.id')),
149                                Column('sent', TIMESTAMP, nullable=False),
150                                Column(
151                                    'recipient', Unicode(255), nullable=False),
152                                )
153    vacation_mapper = mapper(Vacation, vacation_table)
154
155    vacationreply_mapper = mapper(VacationReply, vacationreply_table,
156                                  properties=dict(
157                                      vacation=relation(
158                                          Vacation, backref='replies'),
159                                  )
160                                  )
161
162
163class VacationCache(object):
164
165    """caches vacation and compiled regex patterns"""
166
167    __shared_state = {}
168
169    def __init__(self, config):
170        self.__dict__ = self.__shared_state
171        if not hasattr(self, 'vacations'):
172            self.vacations = {}
173        if not hasattr(self, 'lock'):
174            self.lock = Lock()
175        if not hasattr(self, 'logger'):
176            self.logger = logging.getLogger('fuglu.plugin.vacation.Cache')
177        if not hasattr(self, 'lastreload'):
178            self.lastreload = 0
179        self.config = config
180        self.reloadifnecessary()
181
182    def reloadifnecessary(self):
183        """reload vacation if older than 60 seconds"""
184        if not time.time() - self.lastreload > 60:
185            return
186        if not self.lock.acquire():
187            return
188        try:
189            self._loadvacation()
190        finally:
191            self.lock.release()
192
193    def _loadvacation(self):
194        """loads all vacations from database, do not call directly, only through reloadifnecessary"""
195        self.logger.debug('Reloading vacation...')
196
197        self.lastreload = time.time()
198
199        newvacations = {}
200        dbsession = get_session(self.config.get('VacationPlugin', 'dbconnectstring'), expire_on_commit=False)
201        vaccounter = 0
202        now = datetime.now()
203        for vac in dbsession.query(Vacation).filter_by(enabled=True).filter(Vacation.start < now).filter(Vacation.end > now):
204            vaccounter += 1
205            self.logger.debug(vac)
206            newvacations[vac.awayuser] = vac
207        # important to expunge or other sessions wont be able to use this
208        # vacation object
209        dbsession.expunge_all()
210        self.vacations = newvacations
211        self.logger.debug('%s vacations loaded' % vaccounter)
212
213
214class VacationPlugin(ScannerPlugin):
215
216    """Sends out-of-office reply messages. Configuration is trough a sql database. Replies are only sent once per day per sender. The plugin will not reply to any 'automated' messages (Mailingslists, Spams, Bounces etc)
217
218Requires: SQLAlechemy Extension
219
220
221Required DB Tables:
222 * vacation (fuglu reads this table only, must be filled from elsewhere)
223
224   * id int : id of this vacation
225   * created timestamp :  creation timestamp
226   * enabled boolean (eg. tinyint) : if disabled, no vacation reply will be sent
227   * start timestamp: replies will only be sent after this point in time
228   * end timestamp: replies will only be sent before this point in time
229   * awayuser varchar: the email address of the user that is on vacation
230   * subject: subject of the vacation message
231   * body : body of the vacation message
232   * ignoresender: whitespace delimited list of domains or email addresses that should not receive vacation replies
233
234 * vacationreply (this table is filled by fuglu)
235
236   * id int: id of the reply
237   * vacation_id : id of the vacation
238   * sent timestamp: timestamp when the reply was sent
239   * recipient: recipient to whom the reply was sent
240
241SQL Example for mysql:
242
243::
244
245    CREATE TABLE `vacation` (
246      `id` int(11) NOT NULL auto_increment,
247      `created` timestamp NOT NULL default now(),
248      `start` timestamp NOT NULL,
249      `end` timestamp NOT NULL ,
250      `enabled` tinyint(1) NOT NULL default 1,
251      `awayuser` varchar(255) NOT NULL,
252      `subject` varchar(255) NOT NULL,
253      `body` text NOT NULL,
254      `ignoresender` text NOT NULL,
255      PRIMARY KEY  (`id`),
256      UNIQUE(`awayuser`)
257    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;
258
259
260    CREATE  TABLE `vacationreply` (
261      `id` int(11) NOT NULL auto_increment,
262      `recipient` varchar(255) NOT NULL,
263      `vacation_id` int(11) NOT NULL,
264         `sent` timestamp not null default now(),
265      PRIMARY KEY  (`id`),
266      KEY `vacation_id` (`vacation_id`),
267      CONSTRAINT `vacation_ibfk_1` FOREIGN KEY (`vacation_id`) REFERENCES `vacation` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
268    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
269
270
271"""
272
273    def __init__(self, config, section=None):
274        ScannerPlugin.__init__(self, config, section)
275        self.requiredvars = {
276            'dbconnectstring': {
277                'default': '',
278                'description': 'sqlalchemy connectstring to load vacations',
279                'confidential': True,
280            },
281        }
282
283        self.logger = self._logger()
284        self.cache = None
285
286    def __str__(self):
287        return "Vacation"
288
289    def _cache(self):
290        if self.cache == None:
291            self.cache = VacationCache(self.config)
292        return self.cache
293
294    def examine(self, suspect):
295        # this plugin should not cause fuglu to defer
296        try:
297            vac = self.should_send_vacation_message(suspect)
298            if vac != None:
299                self.logger.debug('Vacation message candidate detected: Sender: %s recipient(on vacation): %s' % (
300                    suspect.from_address, suspect.to_address))
301                self.send_vacation_reply(suspect, vac)
302        except Exception as e:
303            exc = traceback.format_exc()
304            self.logger.error("Exception in Vacation Plugin: %s" % e)
305            self.logger.error(exc)
306        return DUNNO
307
308    def should_send_vacation_message(self, suspect):
309        """do all necessary checks and return the vacation object if we should send a vacation message"""
310        if suspect.is_spam() or suspect.is_virus():
311            self.logger.debug(
312                'Message already detected as spam or virus, not checking for vacation')
313            return None
314
315        self._cache().reloadifnecessary()
316
317        vacation = self.on_vacation(suspect)
318        if vacation == None:
319            return None
320
321        self.logger.debug(
322            'Recipient is on vacation, performing additional checks')
323        if self.ignore_sender(vacation, suspect):
324            self.logger.debug(
325                'Sender is on user ignore list: %s' % suspect.from_address)
326            return None
327
328        if self.non_human_sender(suspect):
329            self.logger.debug(
330                'Message appears to be from automated source - not sending vacation reply')
331            return None
332
333        if self.already_notified(vacation, suspect.from_address):
334            self.logger.debug(
335                'Sender %s already notified, not sending another vacation reply' % suspect.from_address)
336            return None
337
338        return vacation
339
340    def on_vacation(self, suspect):
341        """return Vacation object if recipient is on vacation, None otherwise"""
342        toaddress = suspect.to_address
343        todomain = suspect.to_domain
344
345        allvacs = self._cache().vacations
346
347        # check for individual vacation
348        if toaddress in allvacs:
349            return allvacs[toaddress]
350
351        # domain wide vacation
352        if todomain in allvacs:
353            return allvacs[todomain]
354
355        return None
356
357    def ignore_sender(self, vacation, suspect):
358        """return true if sender address/domain is on users ignore list"""
359        senderignos = vacation.ignoresender.split()
360        if suspect.from_address in senderignos or suspect.from_domain in senderignos:
361            return True
362        return False
363
364    def non_human_sender(self, suspect):
365        """returns True if this sender is non-human, eg. mailinglist, spam, bounce etc"""
366        sender = suspect.from_address.lower()
367
368        if sender == "" or sender == "<>":
369            self.logger.debug('This is a bounce')
370            return True
371
372        for ignoregex in vacation_ignoresenderregex:
373            if re.search(ignoregex, suspect.from_address, re.I):
374                self.logger.debug('Blacklisted sender: %s' % sender)
375                return True
376
377        messagerep = suspect.get_message_rep()
378        for ignoheader in vacation_ignoreheaderexists:
379            if ignoheader in messagerep:
380                self.logger.debug('Blacklisted header: %s' % ignoheader)
381                return True
382
383        for header, restring in list(vacation_ignoreheaderregex.items()):
384            #self.logger.info("searching for header %s"%header)
385            vals = messagerep.get_all(header)
386            if vals != None:
387                for val in vals:
388                    if re.search(restring, val, re.I):
389                        self.logger.debug(
390                            'Blacklisted header value: %s: %s' % (header, val))
391                        return True
392
393        return False
394
395    def already_notified(self, vacation, recipient):
396        """return true if this user has been notfied in the last 24 hours"""
397        dbsession = get_session(self.config.get(self.section, 'dbconnectstring'))
398        log = dbsession.query(VacationReply).filter_by(vacation=vacation).filter(
399            VacationReply.sent > datetime.now() - timedelta(days=1)).filter_by(recipient=recipient).first()
400        dbsession.expunge_all()
401        if log != None:
402            self.logger.debug(
403                'Sender %s already notfied at %s' % (log.recipient, log.sent))
404            return True
405        return False
406
407    def send_vacation_reply(self, suspect, vacation):
408        """send the vacation reply"""
409        # http://mg.pov.lt/blog/unicode-emails-in-python
410
411        bounce = Bounce(self.config)
412        self.logger.debug('generating vacation message from %s to %s' % (
413            suspect.to_address, suspect.from_address))
414
415        # check subject
416        subj = vacation.subject
417        if subj == None or subj.strip() == '':
418            self.logger.errror('Vacation has no subject, not sending message')
419            return None
420
421        # We must choose the body charset manually
422        body = vacation.body
423        if body == None:
424            body = ''
425
426        for body_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8':
427            try:
428                body.encode(body_charset)
429            except UnicodeError:
430                pass
431            else:
432                break
433
434        msg = MIMEText(body.encode(body_charset), 'plain', body_charset)
435
436        h = Header(vacation.subject, 'ISO-8859-1')
437        msg['Subject'] = h
438        msg['Precedence'] = 'bulk'
439        msg['Auto-Submitted'] = 'auto-replied'
440        msg['From'] = suspect.to_address
441        msg['To'] = suspect.from_address
442
443        msgcontent = msg.as_string()
444        bounce.send_template_string(
445            suspect.from_address, msgcontent, suspect, dict())
446        self.log_bounce(suspect, vacation)
447        return msgcontent
448
449    def log_bounce(self, suspect, vacation):
450        """log a bounce so we know tho whom we already sent"""
451        log = VacationReply()
452        log.recipient = suspect.from_address
453        log.sent = datetime.now()
454        log.vacation = vacation
455
456        dbsession = get_session(self.config.get(self.section, 'dbconnectstring'))
457        dbsession.add(log)
458        dbsession.flush()
459        dbsession.expunge_all()
460
461    def lint(self):
462        allok = self.check_config() and self.lint_sql()
463        return allok
464
465    def lint_sql(self):
466        if not SQL_EXTENSION_ENABLED:
467            print("Vacation requires the fuglu sql extension to be enabled")
468            return False
469
470        try:
471            dbsession = get_session(self.config.get(self.section, 'dbconnectstring'))
472            bind = dbsession.get_bind(Vacation)
473            bind.connect()
474            now = datetime.now()
475            allvacs = dbsession.query(Vacation).filter_by(enabled=True).filter(
476                Vacation.start < now).filter(Vacation.end > now)
477            for vac in allvacs:
478                print(vac)
479        except Exception as e:
480            print("Database error: %s" % str(e))
481            return False
482
483        return True
484