first reimport from platal
[platal.git] / bin / smtp_bounce_proxy.py
CommitLineData
0337d704 1#! /usr/bin/python
2# set:encoding=iso-8859-1:
3
4import asyncore
5import email
6import os, re, sys
7
8from email import Message, MIMEText, MIMEMultipart
9from email.Iterators import typed_subpart_iterator, _structure
10from smtpd import PureProxy
11
12import ConfigParser
13import MySQLdb
14
15IGNORE = 0
16NOTICE = 1
17ERROR = 2
18
19FROM_PORT = 20024
20TO_HOST = 'olympe.madism.org'
21TO_PORT = 25
22
23
24################################################################################
25#
26# Functions
27#
28#-------------------------------------------------------------------------------
29
30config = ConfigParser.ConfigParser()
31config.read(os.path.dirname(__file__)+'/../configs/platal.conf')
32
33def 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
43def 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
52def msg_of_str(data): return email.message_from_string(data, _class=BounceMessage)
53
54################################################################################
55#
56# Classes
57#
58#-------------------------------------------------------------------------------
59
60class 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
225class 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
259mysql = connectDB()
260Proxy = BounceProxy(('127.0.0.1', FROM_PORT), (TO_HOST, TO_PORT))
261asyncore.loop()
262