| 1 | #! /usr/bin/python |
| 2 | # set:encoding=iso-8859-1: |
| 3 | |
| 4 | import asyncore |
| 5 | import email |
| 6 | import os, re, sys |
| 7 | |
| 8 | from email import Message, MIMEText, MIMEMultipart |
| 9 | from email.Iterators import typed_subpart_iterator, _structure |
| 10 | from smtpd import PureProxy |
| 11 | |
| 12 | import ConfigParser |
| 13 | import MySQLdb |
| 14 | |
| 15 | IGNORE = 0 |
| 16 | NOTICE = 1 |
| 17 | ERROR = 2 |
| 18 | |
| 19 | FROM_PORT = 20024 |
| 20 | TO_HOST = 'olympe.madism.org' |
| 21 | TO_PORT = 25 |
| 22 | |
| 23 | |
| 24 | ################################################################################ |
| 25 | # |
| 26 | # Functions |
| 27 | # |
| 28 | #------------------------------------------------------------------------------- |
| 29 | |
| 30 | config = ConfigParser.ConfigParser() |
| 31 | config.read(os.path.dirname(__file__)+'/../configs/platal.conf') |
| 32 | |
| 33 | def get_config(sec,val,default=None): |
| 34 | try: |
| 35 | return config.get(sec, val)[1:-1] |
| 36 | except ConfigParser.NoOptionError, e: |
| 37 | if default is None: |
| 38 | print e |
| 39 | sys.exit(1) |
| 40 | else: |
| 41 | return default |
| 42 | |
| 43 | def connectDB(): |
| 44 | db = MySQLdb.connect( |
| 45 | db = 'x4dat', |
| 46 | user = get_config('Core', 'dbuser'), |
| 47 | passwd = get_config('Core', 'dbpwd'), |
| 48 | unix_socket='/var/run/mysqld/mysqld.sock') |
| 49 | db.ping() |
| 50 | return db.cursor() |
| 51 | |
| 52 | def msg_of_str(data): return email.message_from_string(data, _class=BounceMessage) |
| 53 | |
| 54 | ################################################################################ |
| 55 | # |
| 56 | # Classes |
| 57 | # |
| 58 | #------------------------------------------------------------------------------- |
| 59 | |
| 60 | class BounceMessage(Message.Message): |
| 61 | def body(self): |
| 62 | """this method returns the part that is commonely designed as the 'body' |
| 63 | |
| 64 | for the multipart mails, we go into the first part that have non multipart childs, and then : |
| 65 | we return its first text/plain part if it exsists |
| 66 | else we return the first text/* part if it exists |
| 67 | else we return None else |
| 68 | |
| 69 | for non multipart mails, we just return the current payload |
| 70 | """ |
| 71 | if self.is_multipart(): |
| 72 | _body = self |
| 73 | while _body.get_payload(0).is_multipart(): |
| 74 | _body = _body.get_payload(0) |
| 75 | |
| 76 | buffer = None |
| 77 | for part in typed_subpart_iterator(_body): |
| 78 | if part.get_content_subtype() == 'plain': |
| 79 | return part.get_payload(decode=True) |
| 80 | if buffer is None: |
| 81 | buffer = part |
| 82 | return buffer.get_payload(decode=True) |
| 83 | return self.get_payload(decode=True) |
| 84 | |
| 85 | def _qmail_attached_mail(self): |
| 86 | """qmail is a dumb MTA that put the mail that has bounced RAW into the bounce message, |
| 87 | instead of making a traditionnal message/rfc822 attachement like any other MTA |
| 88 | |
| 89 | it seems to be designed like this : |
| 90 | |
| 91 | ============================================= |
| 92 | [...QMAIL crap...] |
| 93 | --- Below this line is a copy of the message. |
| 94 | |
| 95 | Return-Path: <...> |
| 96 | [rest of the embeded mail] |
| 97 | ============================================= |
| 98 | |
| 99 | so we just cut the qmail crap, and build a new message from the rest. |
| 100 | |
| 101 | may DJB burn into coder's hell |
| 102 | """ |
| 103 | msg = self.get_payload(decode=True) |
| 104 | pos = msg.find("\n--- Below this line is a copy of the message.") |
| 105 | if pos is -1: |
| 106 | return None |
| 107 | pos = msg.find("Return-Path:", pos) |
| 108 | return msg_of_str(msg[pos:]) |
| 109 | |
| 110 | def attached_mail(self): |
| 111 | """returns the attached mail that bounced, if it exists. |
| 112 | we try this : |
| 113 | |
| 114 | is the mail multipart ? |
| 115 | Yes : |
| 116 | (1) return the first message/rfc822 part. |
| 117 | (2) return the first text/rfc822-headers part (AOHell) |
| 118 | (3) return None (may be a vacation + some disclaimer in attachment) |
| 119 | No: |
| 120 | try to return the qmail-style embeded mail (but may be a vacation) |
| 121 | """ |
| 122 | if self.is_multipart(): |
| 123 | for part in typed_subpart_iterator(self, 'message', 'rfc822'): |
| 124 | return part |
| 125 | for part in typed_subpart_iterator(self, 'text', 'rfc822-headers'): |
| 126 | return part |
| 127 | return None |
| 128 | return self._qmail_attached_mail() |
| 129 | |
| 130 | def error_level(self): |
| 131 | """determine the level of an error: |
| 132 | IGNORE == drop the mail |
| 133 | NOTICE == vacation, or any informative message we want to forward as is |
| 134 | ERROR == errors, that we want to handle |
| 135 | """ |
| 136 | |
| 137 | body = self.body() |
| 138 | if not body: |
| 139 | return (IGNORE, '') |
| 140 | |
| 141 | mysql.execute ( "SELECT lvl,re,text FROM emails_bounces_re ORDER BY pos" ) |
| 142 | nb = int(mysql.rowcount) |
| 143 | for x in range(0,nb): |
| 144 | row = mysql.fetchone() |
| 145 | if re.compile(str(row[1]), re.I | re.M).search(body): |
| 146 | return (int(row[0]), str(row[2])) |
| 147 | |
| 148 | return (NOTICE, '') |
| 149 | |
| 150 | def forge_error(self, alias, dest, txt): |
| 151 | """we have to do our little treatments for the broken mail, |
| 152 | and then we create an informative message for the original SENDER to : |
| 153 | - explain to him what happened (the detailed error) |
| 154 | - try to guess if the user may or may not have had the mail (by another leg) |
| 155 | - if no other leg, give an information to the SENDER on how he can give to us a real good leg |
| 156 | and attach any sensible information about the original mail (@see attached_mail) |
| 157 | """ |
| 158 | |
| 159 | mysql.execute("SELECT id FROM aliases WHERE alias='%s' AND type IN ('alias', 'a_vie') LIMIT 1" % (alias)) |
| 160 | if int(mysql.rowcount) is not 1: |
| 161 | return None |
| 162 | uid = mysql.fetchone()[0] |
| 163 | mysql.execute("UPDATE emails SET panne = NOW() WHERE uid='%s' AND email='%s'" % (uid, dest)) |
| 164 | mysql.execute("REPLACE INTO emails_broken (uid,email) VALUES(%s, '%s')" % (uid, dest)) |
| 165 | mysql.execute("""SELECT COUNT(*), |
| 166 | IFNULL(SUM(panne=0 OR (last!=0 AND ( TO_DAYS(NOW())-TO_DAYS(last) )>7 AND panne<last)), 0), |
| 167 | IFNULL(SUM(panne!=0 AND last!=0 AND ( TO_DAYS(NOW())-TO_DAYS(last) )<7 AND panne<last) , 0), |
| 168 | IFNULL(SUM(panne!=0 AND (last=0 OR ( TO_DAYS(NOW())-TO_DAYS(last) )<1)) , 0) |
| 169 | FROM emails |
| 170 | WHERE FIND_IN_SET('active', flags) AND uid=%s AND email!='%s'""" % (uid, dest)) |
| 171 | |
| 172 | nb_act, nb_ok, nb_may, nb_bad = map(lambda x: int(x), mysql.fetchone()) |
| 173 | |
| 174 | txt = "Une des adresses de redirection de %s\n" % (alias) \ |
| 175 | + "a généré une erreur (qui peut être temporaire) :\n" \ |
| 176 | + "------------------------------------------------------------\n" \ |
| 177 | + "%s\n" % (txt) \ |
| 178 | + "------------------------------------------------------------\n\n" |
| 179 | |
| 180 | if nb_ok + nb_may is 0: |
| 181 | txt += "Toutes les adresses de redirection de ce correspondant\n" \ |
| 182 | + "sont cassées à l'heure actuelle.\n\n" \ |
| 183 | + "Prière de prévenir votre correspondant par d'autres moyens\n" \ |
| 184 | + "pour lui signaler ce problème et qu'il puisse le corriger !!!" |
| 185 | elif nb_ok is 0: |
| 186 | txt += "Ce correspondant possède néanmoins %i autre(s) adresse(s) active(s)\n" % (nb_may) \ |
| 187 | + "en erreur, mais ayant recu des mails dans les 7 derniers jours,\n" \ |
| 188 | + "sans -- pour le moment -- avoir créé la moindre nouvelle erreur.\n\n" \ |
| 189 | + "Ces adresses sont donc peut-être valides.\n" |
| 190 | else: |
| 191 | txt += "Ce correspondant a en ce moment %i autre(s) adresse(s) valide(s).\n" % (nb_ok) \ |
| 192 | + "Rien ne prouve cependant qu'elles étaient actives \n" \ |
| 193 | + "au moment de l'envoi qui a échoué." |
| 194 | |
| 195 | msg = MIMEMultipart.MIMEMultipart() |
| 196 | msg['Subject'] = self['Subject'] |
| 197 | |
| 198 | attach = self.attached_mail() |
| 199 | if attach is not None: |
| 200 | txt += "\nCi-joint le mail dont la livraison a échoué\n" |
| 201 | msg.attach(MIMEText.MIMEText(txt)) |
| 202 | msg.attach(attach) |
| 203 | else: |
| 204 | msg.attach(MIMEText.MIMEText(txt)) |
| 205 | |
| 206 | return msg |
| 207 | |
| 208 | def to_bounce(self, alias, dest): |
| 209 | """this function returns a new Message, the one we really want to send. |
| 210 | |
| 211 | alias holds one valide plat/al alias of the user |
| 212 | |
| 213 | Case 0: the error is IGNORE : return None |
| 214 | Case 1: the error is NOTICE : we just return self |
| 215 | Case 2: we have a REAL error: use forge_error |
| 216 | """ |
| 217 | lvl, txt = self.error_level() |
| 218 | |
| 219 | if lvl is IGNORE: return None |
| 220 | elif lvl is NOTICE: return self |
| 221 | elif lvl is ERROR : return self.forge_error(alias, dest, txt) |
| 222 | else: raise |
| 223 | |
| 224 | |
| 225 | class BounceProxy(PureProxy): |
| 226 | def __init__(self, localaddr, remoteaddr): |
| 227 | PureProxy.__init__(self, localaddr, remoteaddr) |
| 228 | self._rcpt_re = re.compile(r'^([^_]*)__(.*)__([^_+=]*)\+(.*)=([^_+=]*)@bounces.m4x.org$') |
| 229 | |
| 230 | |
| 231 | def process_rcpt(self, rcpttos): |
| 232 | for to in rcpttos: |
| 233 | m = self._rcpt_re.match(to) |
| 234 | if m is None: continue |
| 235 | return ( m.group(1), m.group(2)+'@'+m.group(3), m.group(4)+'@'+m.group(5) ) |
| 236 | return None |
| 237 | |
| 238 | |
| 239 | def process_message(self, peer, mailfrom, rcpttos, data): |
| 240 | try: |
| 241 | alias, sender, dest = self.process_rcpt(rcpttos) |
| 242 | bounce = msg_of_str(data).to_bounce(alias, dest) |
| 243 | if bounce is not None: |
| 244 | bounce['From'] = """"Serveur de courier Polytechnique.org" <MAILER-DAEMON@bounces.m4x.org>""" |
| 245 | bounce['To'] = sender |
| 246 | self._deliver("MAILER-DAEMON@bounces.m4x.org", [sender], bounce.as_string()) |
| 247 | except: |
| 248 | pass |
| 249 | # SPAM or broken msg, we just drop it |
| 250 | return None |
| 251 | |
| 252 | |
| 253 | ################################################################################ |
| 254 | # |
| 255 | # Main |
| 256 | # |
| 257 | #------------------------------------------------------------------------------- |
| 258 | |
| 259 | mysql = connectDB() |
| 260 | Proxy = BounceProxy(('127.0.0.1', FROM_PORT), (TO_HOST, TO_PORT)) |
| 261 | asyncore.loop() |
| 262 | |