0337d704 |
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 | |