diff --git a/main.py b/main.py new file mode 100644 index 0000000..b8616db --- /dev/null +++ b/main.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +from twisted.cred.checkers import ICredentialsChecker +from twisted.cred import credentials, error +from twisted.python import failure +from twisted.internet import reactor, defer +from twisted.internet.task import LoopingCall + +from zope.interface import implements + +from mech_smtp import SerpentSMTPFactory, smtp_portal +from mech_imap import SerpentIMAPFactory, imap_portal +from serpent.usrpwd import dbs +from serpent.queue import squeue +from serpent.config import conf + +class CredChecker(object): + '''Класс проверки данных авторизации. +Параметром в конструктор передаётся список (list()) объектов баз пользователей.''' + implements(ICredentialsChecker) + credentialInterfaces = (credentials.IUsernamePassword, + credentials.IUsernameHashedPassword) + + def __init__(self, dbs): + self.dbs = dbs + + def _cbPasswordMatch(self, matched, username): + if matched: + return username + else: + return failure.Failure(error.UnauthorizedLogin()) + + def requestAvatarId(self, credentials): + found_user = False + for db in self.dbs: + found_user = db.user_exist(credentials.username) + if found_user: + pwdfunc = db.check_pw + break + if found_user: + return defer.maybeDeferred( + pwdfunc, [credentials.username, credentials.password]).addCallback( + self._cbPasswordMatch, str(credentials.username)) + else: + return defer.fail(error.UnauthorizedLogin()) + +checker = CredChecker(dbs) +smtp_portal.registerChecker(checker) +smtp_factory = SerpentSMTPFactory(smtp_portal) +imap_portal.registerChecker(checker) +imap_factory = SerpentIMAPFactory(imap_portal) + +reactor.listenTCP(2500, smtp_factory) +reactor.listenTCP(1430, imap_factory) + +qtask = LoopingCall(squeue.run) +qtask.start(conf.smtp_queue_check_period) + +reactor.run() \ No newline at end of file diff --git a/mech_imap.py b/mech_imap.py new file mode 100644 index 0000000..0add5e6 --- /dev/null +++ b/mech_imap.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- + +import os + +from zope.interface import implements + +from twisted.cred import portal +from twisted.internet import protocol, ssl +from twisted.mail import imap4 + +from serpent.config import conf +from serpent.imap.mailbox import IMAPMailbox +from serpent.misc import IMAP_HDELIM, IMAP_MBOX_REG, IMAP_ACC_CONN_NUM +from shutil import rmtree, move + +class IMAPUserAccount(object): + implements(imap4.IAccount) + + def __init__(self, mdir): + if not os.path.exists(mdir): + os.makedirs(mdir) + self.dir = mdir + if self.dir in IMAP_MBOX_REG.keys(): + IMAP_MBOX_REG[self.dir][IMAP_ACC_CONN_NUM] += 1 + else: + IMAP_MBOX_REG[self.dir] = {} + IMAP_MBOX_REG[self.dir][IMAP_ACC_CONN_NUM] = 0 + for m in conf.imap_auto_mbox: + if m not in IMAP_MBOX_REG[self.dir].keys(): + if isinstance(m, unicode): + m = m.encode('imap4-utf-7') + IMAP_MBOX_REG[self.dir][m] = IMAPMailbox(os.path.join(self.dir, m)) + IMAP_MBOX_REG[self.dir][m]._start_monitor() + self.subscribe(m) + + def _getMailbox(self, path): + if isinstance(path, unicode): + path = path.encode('imap4-utf-7') + fullPath = os.path.join(self.dir, path) + mbox = IMAPMailbox(fullPath) + mbox._start_monitor() + return mbox + + def listMailboxes(self, ref, wildcard): + for box in os.listdir(self.dir): + yield box.decode('imap4-utf-7'), self.create(box) + + def select(self, path, rw=False): + if isinstance(path, unicode): + path = path.encode('imap4-utf-7') + if path in IMAP_MBOX_REG[self.dir].keys(): + return IMAP_MBOX_REG[self.dir][path] + else: + if path in os.listdir(self.dir): + return self.create(path) + else: + raise imap4.NoSuchMailbox, path + + def addMailbox(self, name, mbox = None): + if mbox: + raise NotImplementedError + return self.create(name) + + def create(self, pathspec): + if isinstance(pathspec, unicode): + pathspec = pathspec.encode('imap4-utf-7') + if pathspec not in IMAP_MBOX_REG[self.dir].keys(): + paths = filter(None, pathspec.split(IMAP_HDELIM)) + for accum in range(1, len(paths)): + subpath = IMAP_HDELIM.join(paths[:accum]) + if subpath not in IMAP_MBOX_REG[self.dir].keys(): + try: + IMAP_MBOX_REG[self.dir][subpath] = self._getMailbox(IMAP_HDELIM.join(paths[:accum])) + except imap4.MailboxCollision: + pass + IMAP_MBOX_REG[self.dir][pathspec] = self._getMailbox(pathspec) + return IMAP_MBOX_REG[self.dir][pathspec] + + def delete(self, pathspec): + if isinstance(pathspec, unicode): + pathspec = pathspec.encode('imap4-utf-7') + if pathspec in conf.imap_auto_mbox: + raise imap4.MailboxException, pathspec + if pathspec not in IMAP_MBOX_REG[self.dir].keys(): + raise imap4.MailboxException("No such mailbox") + inferiors = self._inferiorNames(pathspec) + if r'\Noselect' in IMAP_MBOX_REG[self.dir][pathspec].getFlags(): + # Check for hierarchically inferior mailboxes with this one + # as part of their root. + for inferior in inferiors: + if inferior != pathspec: + raise imap4.MailboxException, "Hierarchically inferior mailboxes exist and \\Noselect is set" + for inferior in inferiors: + mdir = IMAP_MBOX_REG[self.dir][inferior].path + IMAP_MBOX_REG[self.dir][inferior].destroy() + del IMAP_MBOX_REG[self.dir][inferior] + rmtree(mdir) + return True + + def rename(self, oldname, newname): + if oldname in conf.imap_auto_mbox: + raise imap4.MailboxException, oldname + if isinstance(oldname, unicode): + oldname = oldname.encode('imap4-utf-7') + if isinstance(newname, unicode): + newname = newname.encode('imap4-utf-7') + if oldname not in IMAP_MBOX_REG[self.dir].keys(): + raise imap4.NoSuchMailbox, oldname + inferiors = [(o, o.replace(oldname, newname, 1)) for o in self._inferiorNames(oldname)] + for (old, new) in inferiors: + if new in IMAP_MBOX_REG[self.dir].keys(): + raise imap4.MailboxCollision, new + for (old, new) in inferiors: + move(os.path.join(self.dir, old), os.path.join(self.dir, new)) + IMAP_MBOX_REG[self.dir][new] = IMAP_MBOX_REG[self.dir][old] + IMAP_MBOX_REG[self.dir][new].path = os.path.join(self.dir, new) + del IMAP_MBOX_REG[self.dir][old] + return True + + def subscribe(self, name): + if isinstance(name, unicode): + name = name.encode('imap4-utf-7') + if name in IMAP_MBOX_REG[self.dir].keys(): + IMAP_MBOX_REG[self.dir][name].flags['subscribed'] = True + IMAP_MBOX_REG[self.dir][name]._save_flags() + + def unsubscribe(self, name): + if isinstance(name, unicode): + name = name.encode('imap4-utf-7') + if name in IMAP_MBOX_REG[self.dir].keys(): + IMAP_MBOX_REG[self.dir][name].flags['subscribed'] = False + IMAP_MBOX_REG[self.dir][name]._save_flags() + + def isSubscribed(self, name): + if isinstance(name, unicode): + name = name.encode('imap4-utf-7') + return IMAP_MBOX_REG[self.dir][name].flags['subscribed'] + + def _inferiorNames(self, name): + name_l = name.split(IMAP_HDELIM) + inferiors = [] + for infname in IMAP_MBOX_REG[self.dir].keys(): + if name_l == infname.split(IMAP_HDELIM)[:len(name_l)]: + inferiors.append(infname) + return inferiors + +class SerpentIMAPRealm(object): + implements(portal.IRealm) + + def requestAvatar(self, avatarId, mind, *interfaces): + if imap4.IAccount not in interfaces: + raise NotImplementedError( + "This realm only supports the imap4.IAccount interface.") + mdir = os.path.join(conf.app_dir, conf.maildir_user_path % avatarId) + avatar = IMAPUserAccount(mdir) + return imap4.IAccount, avatar, lambda: None + +############################################################################### + +class IMAPServerProtocol(imap4.IMAP4Server): + def lineReceived(self, line): + print "CLIENT:", line + imap4.IMAP4Server.lineReceived(self, line) + + def sendLine(self, line): + imap4.IMAP4Server.sendLine(self, line) + print "SERVER:", line + + def connectionLost(self, reason): + self.setTimeout(None) + if self.account and self.account.dir in IMAP_MBOX_REG.keys(): + IMAP_MBOX_REG[self.account.dir][IMAP_ACC_CONN_NUM] -= 1 + if IMAP_MBOX_REG[self.account.dir][IMAP_ACC_CONN_NUM] <= 0: + for m in IMAP_MBOX_REG[self.account.dir].keys(): + if m == IMAP_ACC_CONN_NUM: + continue + IMAP_MBOX_REG[self.account.dir][m].close() + del IMAP_MBOX_REG[self.account.dir][m] + del IMAP_MBOX_REG[self.account.dir] + self.account = None + + def _parseMbox(self, name): + if isinstance(name, unicode): + return name + try: + return name.decode('imap4-utf-7') + except: + #log.err() + raise imap4.IllegalMailboxEncoding(name) + + def _cbCopySelectedMailbox(self, mbox, tag, messages, mailbox, uid): + if not isinstance(mbox, IMAPMailbox): + self.sendNegativeResponse(tag, 'No such mailbox: ' + mailbox) + else: + imap4.maybeDeferred(self.mbox.fetch, messages, uid + ).addCallback(self.__cbCopy, tag, mbox + ).addCallback(self.__cbCopied, tag, mbox + ).addErrback(self.__ebCopy, tag + ) + + def __cbCopy(self, messages, tag, mbox): + # XXX - This should handle failures with a rollback or something + addedDeferreds = [] + fastCopyMbox = imap4.IMessageCopier(mbox, None) + for (_id, msg) in messages: + if fastCopyMbox is not None: + d = imap4.maybeDeferred(fastCopyMbox.copy, msg) + addedDeferreds.append(d) + continue + # XXX - The following should be an implementation of IMessageCopier.copy + # on an IMailbox->IMessageCopier adapter. + flags = msg.getFlags() + date = msg.getInternalDate() + body = imap4.IMessageFile(msg, None) + if body is not None: + bodyFile = body.open() + d = imap4.maybeDeferred(mbox.addMessage, bodyFile, flags, date) + else: + def rewind(f): + f.seek(0) + return f + _buffer = imap4.tempfile.TemporaryFile() + d = imap4.MessageProducer(msg, _buffer, self._scheduler + ).beginProducing(None + ).addCallback(lambda _, b=_buffer, f=flags, d=date: mbox.addMessage(rewind(b), f, d) + ) + addedDeferreds.append(d) + return imap4.defer.DeferredList(addedDeferreds) + def __cbCopied(self, deferredIds, tag, mbox): + ids = [] + failures = [] + for (status, result) in deferredIds: + if status: + ids.append(result) + else: + failures.append(result.value) + if failures: + self.sendNegativeResponse(tag, '[ALERT] Some messages were not copied') + else: + self.sendPositiveResponse(tag, 'COPY completed') + def __ebCopy(self, failure, tag): + self.sendBadResponse(tag, 'COPY failed:' + str(failure.value)) + #log.err(failure) + + def _cbAppendGotMailbox(self, mbox, tag, flags, date, message): + if not isinstance(mbox, IMAPMailbox): + self.sendNegativeResponse(tag, '[TRYCREATE] No such mailbox') + return + d = mbox.addMessage(message, flags, date) + d.addCallback(self.__cbAppend, tag, mbox) + d.addErrback(self.__ebAppend, tag) + + def __cbAppend(self, result, tag, mbox): + self.sendUntaggedResponse('%d EXISTS' % mbox.getMessageCount()) + self.sendPositiveResponse(tag, 'APPEND complete') + def __ebAppend(self, failure, tag): + self.sendBadResponse(tag, 'APPEND failed: ' + str(failure.value)) + + def _cbStatusGotMailbox(self, mbox, tag, mailbox, names): + if isinstance(mbox, IMAPMailbox): + + imap4.maybeDeferred(mbox.requestStatus, names).addCallbacks( + self.__cbStatus, self.__ebStatus, + (tag, mailbox), None, (tag, mailbox), None + ) + else: + self.sendNegativeResponse(tag, "Could not open mailbox") + def _ebStatusGotMailbox(self, failure, tag): + self.sendBadResponse(tag, "Server error encountered while opening mailbox.") + #log.err(failure) + + def __cbStatus(self, status, tag, box): + line = ' '.join(['%s %s' % x for x in status.iteritems()]) + if isinstance(box, unicode): + box = box.encode('imap4-utf-7') + self.sendUntaggedResponse('STATUS %s (%s)' % (box, line)) + self.sendPositiveResponse(tag, 'STATUS complete') + def __ebStatus(self, failure, tag, box): + self.sendBadResponse(tag, 'STATUS %s failed: %s' % (box, str(failure.value))) + +################################################################################ + +class SerpentIMAPFactory(protocol.Factory): + def __init__(self, portal): + self.portal = portal + + def buildProtocol(self, addr): + contextFactory = None + if conf.tls: + tls_data = file(conf.tls_pem, 'rb').read() + cert = ssl.PrivateCertificate.loadPEM(tls_data) + contextFactory = cert.options() + p = IMAPServerProtocol(contextFactory = contextFactory) + if conf.tls: + p.canStartTLS = True + p.IDENT = '%s ready' % conf.SRVNAME + p.portal = self.portal + return p +imap_portal = portal.Portal(SerpentIMAPRealm()) diff --git a/mech_smtp.py b/mech_smtp.py new file mode 100644 index 0000000..95aa7a4 --- /dev/null +++ b/mech_smtp.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- + +from serpent.config import conf +from serpent import rules +from serpent.queue import squeue + + +from email.Header import Header +from zope.interface import implements + +from twisted.internet import defer, ssl +from twisted.mail import smtp +from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials + +from twisted.cred.portal import IRealm, Portal + + + + +class SmtpMessageDelivery: + implements(smtp.IMessageDelivery) + + def __init__(self, avatarId = None): + self.avatarId = avatarId + + def receivedHeader(self, helo, origin, recipients): + header = conf.smtp_header.format( + sender_ip = helo[1], + sender_host = helo[0], + srv_host = conf.smtp_hostname, + srv_info = conf.srv_version, + sender = conf.smtp_email_delim.join([origin.local, origin.domain]), + id = self.messageid, + rcpt = conf.smtp_email_delim.join([recipients[0].dest.local, recipients[0].dest.domain]), + date = smtp.rfc822date() + ) + return 'Received: %s' % Header(header) + + def validateFrom(self, helo, origin): # Надо воткнуть всякие проверки хоста по HELO + try: + rules.validateFrom(self, [origin.local, origin.domain]) + except: + raise + else: + return origin + + def validateTo(self, user): + self.messageid = smtp.messageid().split('@')[0].strip('<') + try: + rules.validateTo(self, user) + except: + raise + else: + msg = { + 'from': [user.orig.local, user.orig.domain], + 'rcpt': [user.dest.local, user.dest.domain], + 'transaction_id': self.messageid, + 'id': smtp.messageid().split('@')[0].strip('<') + } + return lambda: SmtpMessage(msg) + +class SmtpMessage: + implements(smtp.IMessage) + + def __init__(self, msg): + self.lines = [] + self.size = 0 + self.msg = msg + + def lineReceived(self, line): + self.lines.append(line) + + def eomReceived(self): + self.lines.append('') + self.msg['message'] = "\n".join(self.lines) + self.lines = None + return defer.succeed(squeue.add(self.msg)) + + def connectionLost(self): + # There was an error, throw away the stored lines + self.lines = None + +class SerpentESMTP(smtp.ESMTP): + def ext_AUTH(self, rest): + if self.canStartTLS and not self.startedTLS: + self.sendCode(538, 'Unencrypted auth denied') + return + if self.authenticated: + self.sendCode(503, 'Already authenticated') + return + parts = rest.split(None, 1) + chal = self.challengers.get(parts[0].upper(), lambda: None)() + if not chal: + self.sendCode(504, 'Unrecognized authentication type') + return + self.mode = smtp.AUTH + self.challenger = chal + if len(parts) > 1: + chal.getChallenge() # Discard it, apparently the client does not + # care about it. + rest = parts[1] + else: + rest = None + self.state_AUTH(rest) + +class SerpentSMTPFactory(smtp.SMTPFactory): + protocol = SerpentESMTP + + def __init__(self, *a, **kw): + smtp.SMTPFactory.__init__(self, *a, **kw) + self.delivery = SmtpMessageDelivery() + + def buildProtocol(self, addr): + contextFactory = None + if conf.tls: + tls_data = file(conf.tls_pem, 'rb').read() + cert = ssl.PrivateCertificate.loadPEM(tls_data) + contextFactory = cert.options() + p = smtp.SMTPFactory.buildProtocol(self, addr) + p.ctx = contextFactory + if conf.tls: + p.canStartTLS = True + p.host = conf.smtp_hostname + p.delivery = self.delivery + p.challengers = {"LOGIN": LOGINCredentials, "PLAIN": PLAINCredentials} + return p + + + +class SmtpRealm: + implements(IRealm) + + def requestAvatar(self, avatarId, mind, *interfaces): + if smtp.IMessageDelivery in interfaces: + return smtp.IMessageDelivery, SmtpMessageDelivery(avatarId), lambda: None + raise NotImplementedError() + + + +smtp_portal = Portal(SmtpRealm()) diff --git a/serpent.pem b/serpent.pem new file mode 100644 index 0000000..d852054 --- /dev/null +++ b/serpent.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIICizCCAfQCCQCUjWJ4YHqnSjANBgkqhkiG9w0BAQsFADCBiTELMAkGA1UEBhMC +UlUxDzANBgNVBAgMBk1vc2NvdzEPMA0GA1UEBwwGTW9zY293MRAwDgYDVQQKDAdN +QksgTGFiMQswCQYDVQQLDAJJVDEZMBcGA1UEAwwQbWFpbC5zZXJwZW50Lm9yZzEe +MBwGCSqGSIb3DQEJARYPc2FsZUBtYmstbGFiLnJ1MB4XDTE1MDQyMTEyMjAxNVoX +DTE2MDQyMDEyMjAxNVowgYkxCzAJBgNVBAYTAlJVMQ8wDQYDVQQIDAZNb3Njb3cx +DzANBgNVBAcMBk1vc2NvdzEQMA4GA1UECgwHTUJLIExhYjELMAkGA1UECwwCSVQx +GTAXBgNVBAMMEG1haWwuc2VycGVudC5vcmcxHjAcBgkqhkiG9w0BCQEWD3NhbGVA +bWJrLWxhYi5ydTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAmLEQkuDxufFO +keco1Wl8jmXMzp07EUFkuYcJYUAeIWSYmXPYs+fMVZS9FIA8jHahx67PgZqOgozY +Yan2VHBfU16H7OUl0GTz7je3rvnBd+WRiEgCVQuN3sM5bYy33DmqWyGJxXk1hFoH +oNJOhyM5nrNZsSO6omNcssfSn/gxZeMCAwEAATANBgkqhkiG9w0BAQsFAAOBgQBE +KOW94kvNPvZC61Rwq3i3LErXH4TKJqltNDLyrKwScMx8qTPPMrr4mYWJaFpO2kiq +jsjNevUFIXrjbsycldIpNqHaqKEg2WdIdTGSClf2/Rkv2zzrCB4chbib6Mn1KuHF +syf1Ypa/BV6cb6jJxfIQljFz6IuZJz4zz2QnJfKROg== +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCYsRCS4PG58U6R5yjVaXyOZczOnTsRQWS5hwlhQB4hZJiZc9iz +58xVlL0UgDyMdqHHrs+Bmo6CjNhhqfZUcF9TXofs5SXQZPPuN7eu+cF35ZGISAJV +C43ewzltjLfcOapbIYnFeTWEWgeg0k6HIzmes1mxI7qiY1yyx9Kf+DFl4wIDAQAB +AoGAFtlQQJp2sbuBZWXw/1aEtA5ZwoVWxHNDrludtLbSi26xQy1JvUovkpLqZHn4 +FZDfDrGDDcLiFnkbHCpB2UrjiKwKSd9z24lqHOmGPbWH+nsj3molN8JtmJD4V89K +ClncRJ+TBQ2aj+vjmDozNJwnNvSc1sS2h8u1RMMe7A3a0NECQQDJ9446vOEaSGZH +7vTa83Igp3MCEmZ+52jJzEilm7SkATp0F17ARKG7ELJlpG1DLxHgoJLE+Ou/6ehN +9Ve88b35AkEAwYq1INLj7e6wA/VVFBWSn0ooHqkdXN75yKQFJOAgEb3yI3iztl3h +ifaxgOjivAxz7LOnKm/WsA4LSBe5WFTpuwJAP2QeFj2WgcNbpxRPcjGbDrjAFlRk +K0zCzSP7YU9/4UIpcKqtKLfh828IL3LugHnTqKd9qalfhXsLWPy6rylJMQJAYOuI +Rva1A6q65FCQGW2wLiqhqrD/rklPBsX0eYHvLVNUlaTVQicDUeaC/04gdRE7YDab +KOo2tZVi2uhefbiQDwJBAIfYntshq7z81u5PQF/qS4sNRZimtm5BtNx+FREheT4h +WaJMjtIrZAcg0VGiBRbIBqMz86lxqfe45nDlL97unuw= +-----END RSA PRIVATE KEY----- diff --git a/serpent/__init__.py b/serpent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serpent/config.py b/serpent/config.py new file mode 100644 index 0000000..cd56f74 --- /dev/null +++ b/serpent/config.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +class Config(object): + pass + +conf = Config() +conf.VERSION = '0.0.1' +conf.SRVNAME = 'Serpent' +conf.srv_version = '%s %s' % (conf.SRVNAME, conf.VERSION) +conf.local_domains = ['dom.lan'] # Список доменов, для которых будет приниматься почта +conf.tls = True +conf.tls_pem = '/home/inpos/ownCloud/workspace/serpent/serpent.pem' +conf.smtp_open_relay = False # Разрешить ли пересылку откуда угодно куда угодно +conf.smtp_email_delim = '@' +conf.smtp_header = '''from [{sender_ip}] (helo={sender_host}) + by {srv_host} with ESMTP ({srv_info}) + (envelope-from <{sender}>) + id {id} + for {rcpt}; {date} +''' +conf.smtp_hostname = 'mail.dom.lan' +conf.app_dir = '/home/inpos/tmp/serpent' +conf.smtp_queue_dir = 'smtp_queue' +conf.smtp_message_size = 40 # Размер в МБ +conf.smtp_queue_check_period = 30 # Передичность запуска обработки очереди в минутах +conf.smtp_queue_message_ttl = 3 * 24 * 60 # Время жизни сообщения в очереди в минутах +conf.maildir_user_path = 'mailstore/%s/' + +conf.imap_SENT = 'Sent' +conf.imap_TRASH = 'Trash' +conf.imap_subscribed = '.subscribed' +conf.imap_flags = 'flags' +conf.imap_auto_mbox = ['INBOX', 'Sent', 'Trash'] +conf.imap_expunge_on_close = True +conf.imap_check_new_interval = 10.0 # Период проверки новых сообщений в ящике \ No newline at end of file diff --git a/serpent/dataio.py b/serpent/dataio.py new file mode 100644 index 0000000..b809dec --- /dev/null +++ b/serpent/dataio.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +import os +import pickle +from glob import iglob +from serpent.config import conf +from serpent.misc import IMAP_FLAGS + +class SmtpFileStore(object): + def __init__(self, dpath): + self.path = dpath + if not os.path.exists(dpath): + os.makedirs(dpath) + def read(self, fid): + try: + with open(os.path.join(self.path, fid), 'rb') as f, open(os.path.join(self.path, fid + '.i'), 'rb') as i: + data = pickle.load(i) + try: + data['message'] = f.read() + except: + raise + return data + except: + #return False + raise + def write(self, data): + fid = data['id'] + try: + with open(os.path.join(self.path, fid), 'wb') as f, open(os.path.join(self.path, fid + '.i'), 'wb') as i: + m = data['message'] + data['message'] = '' + try: + pickle.dump(data, i, 2) + f.write(m) + except: + raise + return True + except: + #return False + raise + def getinfo(self, fid): + try: + with open(os.path.join(self.path, fid + '.i'), 'rb') as i: + data = pickle.load(i) + return data + except: + return False + def setinfo(self, data): + try: + with open(os.path.join(self.path, data['id'] + '.i'), 'wb') as i: + pickle.dump(data, i, 2) + return True + except: + return False + def list(self): + return [i.split('/')[-1].rstrip('\.i') for i in iglob(self.path + '*.i')] + def delete(self, fid): + os.remove(os.path.join(self.path, fid + '.i')) + os.remove(os.path.join(self.path, fid)) + +class MailDirStore(object): + def __init__(self): + from serpent.imap import mailbox + self.mbox = mailbox + def deliver(self, user, message): + mdir = os.path.join(conf.app_dir, conf.maildir_user_path % user) + if not os.path.exists(mdir): + os.makedirs(mdir) + inbox = os.path.join(mdir, 'INBOX') + mailbox = self.mbox.IMAPMailbox(inbox) + try: + mailbox.addMessage(message, [IMAP_FLAGS['RECENT']]) + return True + except: + raise + \ No newline at end of file diff --git a/serpent/errors.py b/serpent/errors.py new file mode 100644 index 0000000..18c4924 --- /dev/null +++ b/serpent/errors.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from twisted.mail import smtp +### Исключения +class SMTPAuthReqError(smtp.SMTPServerError): + '''Класс исключения. Сообщает о необходимости авторизации.''' + def __init__(self): + smtp.SMTPServerError.__init__(self, 550, 'Authentication required!') + +class SMTPNotOpenRelay(smtp.SMTPServerError): + def __init__(self): + smtp.SMTPServerError.__init__(self, 550, 'Not Open Relay!') +### + +SMTPBadRcpt = smtp.SMTPBadRcpt +SMTPBadSender = smtp.SMTPBadSender \ No newline at end of file diff --git a/serpent/imap/__init__.py b/serpent/imap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serpent/imap/mailbox.py b/serpent/imap/mailbox.py new file mode 100644 index 0000000..fe5480e --- /dev/null +++ b/serpent/imap/mailbox.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- + +from twisted.mail import maildir, imap4 +from twisted.mail.smtp import rfc822date +from twisted.internet import inotify +from twisted.python import filepath + +from zope.interface import implements +from threading import Thread + +import random +import email +from pickle import load, dump +from StringIO import StringIO +import os + +from serpent.config import conf +from serpent import misc + +class LoopingTask(Thread): + def __init__(self, func, event, interval): + Thread.__init__(self) + self.func = func + self.interval = interval + self.stopped = event + + def run(self): + while not self.stopped.wait(self.interval): + self.func() + +class SerpentAppendMessageTask(maildir._MaildirMailboxAppendMessageTask): + + def moveFileToNew(self): + while True: + newname = os.path.join(self.mbox.path, "new", maildir._generateMaildirName()) + try: + self.osrename(self.tmpname, newname) + break + except OSError, (err, estr): + import errno + # if the newname exists, retry with a new newname. + if err != errno.EEXIST: + self.fail() + newname = None + break + if newname is not None: + self.mbox.lastadded = newname + self.defer.callback(None) + self.defer = None + + + +class ExtendedMaildir(maildir.MaildirMailbox): + def __iter__(self): + return iter(self.list) + + def __getitem__(self, i): + return self.list[i] + +class IMAPMailbox(ExtendedMaildir): + implements(imap4.IMailbox, imap4.ICloseableMailbox) + + AppendFactory = SerpentAppendMessageTask + + def __init__(self, path): + maildir.initializeMaildir(path) + self.listeners = [] + self.path = path + self.lastadded = None + if not os.path.exists(os.path.join(path, conf.imap_flags)): + self.__init_flags_() + else: + self.__load_flags_() + self.__check_flags() + + def _start_monitor(self): + self.notifier = inotify.INotify() + self.notifier.startReading() + self.notifier.watch(filepath.FilePath(os.path.join(self.path, 'new')), + callbacks=[self._new_files]) + self.notifier.watch(filepath.FilePath(os.path.join(self.path,'cur')), + callbacks=[self._new_files]) + + def _new_files(self, wo, path, code): + if code == inotify.IN_MOVED_TO or code == inotify.IN_DELETE: + for l in self.listeners: + l.newMessages(self.getMessageCount(), self.getRecentCount()) + + def __init_flags_(self): + for fdir in ['new','cur']: + for fn in os.listdir(os.path.join(self.path, fdir)): + if fn not in self.flags['uid'].keys(): + self.flags['uid'][fn] = self.getUIDNext() + if fdir == 'new': + self.flags['flags'][fn] = [] + else: + self.flags['flags'][fn] = misc.IMAP_FLAGS['SEEN'] + self._save_flags() + self.__load_flags_() + + def __load_flags_(self): + try: + self.flags = load(file(os.path.join(self.path, conf.imap_flags), 'rb')) + except: + self.flags = { + 'flags': {}, + 'subscribed': False, + 'uidvalidity': random.randint(0, 2**32), + 'uid': {}, + 'uidnext': 1 + } + self._save_flags() + self.__init_flags_() + + def __check_flags(self): + l = [l for l in self.__msg_list_()] + for i in l: + if i.split('/')[-1] not in self.flags['uid'].keys() or i.split('/')[-1] not in self.flags['flags'].keys(): + if i.split('/')[-2] == 'new': + self.flags['flags'][i.split('/')[-1]] = [] + else: + self.flags['flags'][i.split('/')[-1]] = misc.IMAP_FLAGS['SEEN'] + self.flags['uid'][i.split('/')[-1]] = self.getUIDNext() + + def _save_flags(self): + try: + with open(os.path.join(self.path, conf.imap_flags), 'wb') as f: + dump(self.flags, f, 2) + except: + pass + + def __count_flagged_msgs_(self, flag): + c = 0 + self.__load_flags_() + for dir in ['new','cur']: + for fn in os.listdir(os.path.join(self.path, dir)): + if flag in self.flags['flags'][fn]: + c += 1 + return c + + def getHierarchicalDelimiter(self): + return misc.IMAP_HDELIM + + def getFlags(self): + return misc.IMAP_FLAGS.values() + + def getMessageCount(self): + self.__load_flags_() + c = 0 + c += len([n for n in self.__msg_list_() if misc.IMAP_FLAGS['DELETED'] not in self.flags['flags'][n.split('/')[-1]]]) + return c + + def getRecentCount(self): + return self.__count_flagged_msgs_(misc.IMAP_FLAGS['RECENT']) + + def getUnseenCount(self): + return self.getMessageCount() - self.__count_flagged_msgs_(misc.IMAP_FLAGS['SEEN']) + + def isWriteable(self): + return True + + def getUIDValidity(self): + self.__load_flags_() + return self.flags['uidvalidity'] + + def getUIDNext(self): + self.__load_flags_() + self.flags['uidnext'] += 1 + self._save_flags() + return self.flags['uidnext'] - 1 + + def getUID(self, num): + return num + + def addMessage(self, message, flags = (), date = None): + return self.appendMessage(message).addCallback(self._cbAddMessage, flags) + + def _cbAddMessage(self, obj, flags): + self.__load_flags_() + path = self.lastadded + self.lastadded = None + fn = path.split('/')[-1] + self.flags['uid'][fn] = self.getUIDNext() + self.flags['flags'][fn] = flags + if misc.IMAP_FLAGS['SEEN'] in flags and path.split('/')[-2] != 'cur': + new_path = os.path.join(self.path, 'cur', fn) + os.rename(path, new_path) + self._save_flags() + + def __msg_list_(self): + a = [] + for m in os.listdir(os.path.join(self.path, 'new')): + a.append(os.path.join(self.path, 'new', m)) + for m in os.listdir(os.path.join(self.path, 'cur')): + a.append(os.path.join(self.path, 'cur', m)) + return a + + def _seqMessageSetToSeqDict(self, messageSet): + if not messageSet.last: + messageSet.last = self.getMessageCount() + + seqMap = {} + msgs = self.__msg_list_() + for messageNum in messageSet: + if messageNum > 0 and messageNum <= self.getMessageCount(): + seqMap[messageNum] = msgs[messageNum - 1] + return seqMap + + def fetch(self, messages, uid): + return [[seq, MaildirMessage(seq, + file(filename, 'rb').read(), + self.flags['flags'][filename.split('/')[-1]], + rfc822date())] + for seq, filename in self.__fetch_(messages, uid).iteritems()] + def __fetch_(self, messages, uid): + self.__load_flags_() + if uid: + messagesToFetch = {} + if not messages.last: + messages.last = self.flags['uidnext'] + for uid in messages: + if uid in self.flags['uid'].values(): + for name, _id in self.flags['uid'].iteritems(): + if uid == _id: + if os.path.exists(os.path.join(self.path,'new', name)): + messagesToFetch[uid] = os.path.join(self.path,'new', name) + elif os.path.exists(os.path.join(self.path,'cur', name)): + messagesToFetch[uid] = os.path.join(self.path,'cur', name) + else: + messagesToFetch = self._seqMessageSetToSeqDict(messages) + return messagesToFetch + def store(self, messages, flags, mode, uid): + self.__load_flags_() + d = {} + for _id, path in self.__fetch_(messages, uid).iteritems(): + filename = path.split('/')[-1] + if mode < 0: + old_f = self.flags['flags'][filename] + self.flags['flags'][filename] = list(set(old_f).difference(set(flags))) + if misc.IMAP_FLAGS['SEEN'] in flags and path.split('/')[-2] != 'new': + new_path = os.path.join(self.path, 'new', filename) + os.rename(path, new_path) + elif mode == 0: + self.flags["flags"][filename] = flags + elif mode > 0: + old_f = self.flags['flags'][filename] + self.flags['flags'][filename] = list(set(old_f).union(set(flags))) + if misc.IMAP_FLAGS['SEEN'] in flags and path.split('/')[-2] != 'cur': + new_path = os.path.join(self.path, 'cur', filename) + os.rename(path, new_path) + self._save_flags() + d[_id] = self.flags['flags'][filename] + return d + + def expunge(self): + self.__load_flags_() + uids = [] + for path in self.__msg_list_(): + fn = path.split('/')[-1] + if fn not in self.flags['uid']: + continue + uid = self.flags['uid'][fn] + if misc.IMAP_FLAGS['DELETED'] in self.flags['flags'][fn]: + os.remove(path) + del self.flags['uid'][fn] + del self.flags['flags'][fn] + self._save_flags() + uids.append(uid) + return uids + + def addListener(self, listener): + self.listeners.append(listener) + + def removeListener(self, listener): + self.listeners.remove(listener) + + def requestStatus(self, names): + return imap4.statusRequestHelper(self, names) + + def destroy(self): + pass + + def close(self): + self.notifier.stopReading() + self.notifier.loseConnection() + if conf.imap_expunge_on_close: + l = self.expunge() + +class MaildirMessagePart(object): + implements(imap4.IMessagePart) + + def __init__(self, message): + self.message = message + self.data = str(message) + + def getHeaders(self, negate, *names): + if not names: + names = self.message.keys() + + headers = {} + if negate: + for header in self.message.keys(): + if header.upper() not in names: + headers[header.lower()] = self.message.get(header, '') + else: + for name in names: + headers[name.lower()] = self.message.get(name, '') + + return headers + + def getBodyFile(self): + return StringIO(self.message.get_payload()) + + def getSize(self): + return len(self.data) + + def isMultipart(self): + return self.message.is_multipart() + + def getSubPart(self, part): + return MaildirMessagePart(self.message.get_payload(part)) + +class MaildirMessage(MaildirMessagePart): + implements(imap4.IMessage) + + def __init__(self, uid, message, flags, date): + MaildirMessagePart.__init__(self, message) + self.uid = uid + self.message = email.message_from_string(message) + self.flags = flags + self.date = date + + + def getUID(self): + return self.uid + + def getFlags(self): + return self.flags + + def getInternalDate(self): + return self.date \ No newline at end of file diff --git a/serpent/misc.py b/serpent/misc.py new file mode 100644 index 0000000..f8960d0 --- /dev/null +++ b/serpent/misc.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +MSG_ACTIVE = 0 +MSG_FROZEN = 1 + +IMAP_FLAGS = { + 'SEEN': '\\Seen', + 'FLAGGED': '\\Flagged', + 'ANSWERED': '\\Answered', + 'RECENT': '\\Recent', + 'DELETED': '\\Deleted', + 'DRAFT': '\\Draft' + } +IMAP_HDELIM = '.' +IMAP_ACC_CONN_NUM = '...ConnectionNumber...' +IMAP_MBOX_REG = {} \ No newline at end of file diff --git a/serpent/notes b/serpent/notes new file mode 100644 index 0000000..7305d59 --- /dev/null +++ b/serpent/notes @@ -0,0 +1,23 @@ +######### Запуск приложения под twistd + +from twisted.application import internet, service +from somemodule import EchoFactory + +port = 7001 +factory = EchoFactory() + +# this is the important bit +application = service.Application("echo") # create the Application +echoService = internet.TCPServer(port, factory) # create the service +# add the service to the application +echoService.setServiceParent(application) + +############################################# + def startService(self): + service.Service.startService(self) + + def stopService(self): + service.Service.stopService(self) + if self._call: + self._call.cancel() + self._call = None diff --git a/serpent/queue.py b/serpent/queue.py new file mode 100644 index 0000000..0c5d2cc --- /dev/null +++ b/serpent/queue.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from serpent.config import conf +from serpent import dataio, misc, rules +from datetime import datetime, timedelta +from os import path + +class SmtpQueue(object): + def __init__(self, store, local_delivery): + self.stor = store + self.local_delivery = local_delivery + + def add(self, data): + '''Ставит письмо в очередь''' + data['add_time'] = datetime.utcnow() + data['state'] = misc.MSG_ACTIVE + w = self.stor.write(data) + if not w: + return False + return self.__process_(data['id']) + + def run(self): + '''Запускает обработку очереди''' + now = datetime.utcnow() + check_delta = timedelta(minutes = conf.smtp_queue_check_period) + expire_delta = timedelta(minutes = conf.smtp_queue_message_ttl) + for mid in self.__list_messages_(): + info = self.stor.getinfo(mid) + if (now - info['add_time']) >= expire_delta: + self.stor.delete(mid) + continue + if (now - info['add_time']) >= check_delta: + continue + self.__process_(mid) + return True + + def __local_deliver_(self, mid): + message = self.stor.read(mid) + user = rules.username_by_email(message['rcpt']) + return self.local_delivery.deliver(user, message['message']) + + def __send_email_(self, mid): + pass + + def __freeze_(self, mid): + info = self.stor.getinfo(mid) + if info: + if info['state'] != misc.MSG_FROZEN : + info['state'] = misc.MSG_FROZEN + if self.stor.setinfo(info): + return True + else: + return True + return False + + def __process_(self, mid): + info = self.stor.getinfo(mid) + if info: + if info['rcpt'][1] in conf.local_domains: + if self.__local_deliver_(mid): + self.__remove_message_(mid) + return True + else: + return self.__freeze_(mid) + else: + if self.__send_email_(mid): + self.__remove_message_(mid) + return True + else: + return self.__freeze_(mid) + return False + + def __list_messages_(self): + return self.stor.list() + + def __remove_message_(self, mid): + self.stor.delete(mid) + +mailstore = dataio.MailDirStore() +squeue = SmtpQueue(dataio.SmtpFileStore(path.join(conf.app_dir, conf.smtp_queue_dir)), + mailstore) diff --git a/serpent/rules.py b/serpent/rules.py new file mode 100644 index 0000000..6c1a509 --- /dev/null +++ b/serpent/rules.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from serpent.config import conf +from serpent.usrpwd import dbs +from serpent import errors +'''Здесь находятся функции для проверки различных вещей.''' + +def validateFrom(obj, email): + if not conf.smtp_open_relay: + if email[1] in conf.local_domains: + if not obj.avatarId: + raise errors.SMTPAuthReqError() + elif obj.avatarId != username_by_email(email): + raise errors.SMTPBadSender(conf.smtp_email_delim.join(email)) + elif obj.avatarId: + raise errors.SMTPBadSender(conf.smtp_email_delim.join(email)) + return True + +def validateTo(obj, user): + local = user.dest.local + domain = user.dest.domain + for u, f in user.protocol._to: + if local == u.dest.local and domain == u.dest.domain: + del user.protocol._to[user.protocol._to.index((u,f))] + if domain in conf.local_domains and not username_by_email([local, domain]): + raise errors.SMTPBadRcpt(conf.smtp_email_delim.join([local, domain])) + if domain not in conf.local_domains and not obj.avatarId and not conf.smtp_open_relay: + raise errors.SMTPNotOpenRelay() + return True # Адрес найден в базах пользователей + +def username_by_email(email): + result = None + for db in dbs: + result = db.username_by_email(email) + if result: + break + return result diff --git a/serpent/usrpwd.py b/serpent/usrpwd.py new file mode 100644 index 0000000..d0f407d --- /dev/null +++ b/serpent/usrpwd.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +userdb = { + 'user1': ['password1', 'local1', 'dom.lan'], + 'user2': ['password2', 'local2', 'dom.lan'] + } + +class DictUDB(object): + def __init__(self, userdb = userdb): + self. userdb = userdb + def username_by_email(self, email = None): + if not email: + return None + for usr in self.userdb.keys(): + if email[0] == self.userdb[usr][1] and email[1] == self.userdb[usr][2]: + return usr + return False + def check_pw(self, creds): + usr = creds[0] + pwd = creds[1] + return usr in self.userdb.keys() and pwd == self.userdb[usr][0] + def user_exist(self, username): + return username in self.userdb.keys() +dbs = [DictUDB()] \ No newline at end of file