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