#!/usr/bin/env python """ RSS->IMAP gateway - read your favourite RSS feeds with your favourite email client """ __version__ = "0.1" __license__ = "Python" __copyright__ = "Copyright 2005, Jens Georg" __author__ = "Jens Georg " import sys, random, time, string, re from twisted.python import log from twisted.cred import portal, checkers, credentials from twisted.internet import protocol, reactor, task from twisted.mail import imap4 try: from cStringIO import StringIO except: from StringIO import StringIO from email.Message import Message as Msg from email.Header import Header from email.Utils import formatdate from zope.interface import Interface, implements import rsscache from rsscache import RssCache import md5 from feedmap.Config import Config try: from cPickle import dumps, loads except: from Pickle import dumps, loads import urlparse import textwrap class Message: implements(imap4.IMessage) def __init__(self, entry, uid, encoding = "utf-8", id = "", domain = "localhost", force_html = False): self.entry = entry self.uid = uid self.encoding = string.lower(encoding) self.summary = "" self.is_seen = False self.is_recent = True self.is_deleted = False self.is_flagged = False self.digest = id self.domain = domain self.force_html = force_html #log.msg ("%s will force_html: %s" % (domain, str(self.force_html))) #log.msg("%s Proudly presented in %s" % (domain, encoding)) if self.entry.has_key("modified"): self.date = formatdate(time.mktime(self.entry.modified_parsed)) elif self.entry.has_key("issued"): self.date = formatdate(time.mktime(self.entry.issued_parsed)) elif self.entry.has_key("created"): self.date = formatdate(time.mktime(self.entry.created_parsed)) else: self.date = formatdate(time.time() + uid) if self.entry.has_key("summary"): try: self.summary = textwrap.fill(self.entry.summary.encode(self.encoding)) except: self.summary = textwrap.fill(self.entry.summary.encode("utf-8")) if self.entry.has_key("link"): self.summary = self.summary + "\n\n" + self.entry.link.encode(self.encoding) if self.entry.has_key("content"): self.content = self.entry.content[0].value.encode(self.encoding) self.force_html = True else: self.content = self.summary if self.force_html: self.content_type = "text/html" else: self.content_type = "text/plain" def getUID(self): #log.msg("getUID") return self.uid def getFlags(self): res = [] #log.msg("getFlags") if self.is_seen: res.append("\\Seen") if self.is_recent: res.append("\\Recent") if self.is_deleted: res.append("\\Deleted") if self.is_flagged: res.append("\\Flagged") return res def setFlags(self, flags): #log.msg("old Flags: %s" % str(self.getFlags())) #log.msg("Flags to set: %s" % str(flags)) self.is_seen = False self.is_recent = False self.is_deleted = False self.is_flagged = False for flag in flags: if flag == "\\Seen": self.is_seen = True elif flag == "\\Recent": self.is_recent = True elif flag == "\\Deleted": self.is_deleted = True elif flag == "\\Flagged": self.is_flagged = True else: log.err("Unkown flag: %s" % flag) #log.msg("new Flags: %s" % str(self.getFlags())) def getInternalDate(self): #log.msg("getInternalDate") return self.date def getSize(self): #log.msg("getSize") return len(self.content) def getHeaders(self, negate, *names): #FIXME: Actually look at the headers provided #log.msg("Names: %s %s" % (str(negate), str(names))) headers = {} try: head = self.entry.title.rstrip() except: head = ""; msg = Msg() h = Header(head, self.encoding) msg['Subject'] = h headers["SUBJECT"] = msg.as_string()[9:].rstrip() headers["DATE"] = self.date headers["FROM"] = "" % self.domain headers["TO"] = "Jens Georg " headers["CONTENT-TYPE"] = '%s; charset="%s"' % (self.content_type, self.encoding) headers["CONTENT-ENCODING"] = self.encoding headers["MESSAGE-ID"] = "%s@localhost" % self.digest #log.msg(headers) return headers def isMultipart(self): return False def getBodyFile(self): res = StringIO(self.content) return res class Mailbox(RssCache): implements((imap4.IMailbox, )) def __init__(self, url, uid, expire = 3600, refresh = 3600, force_html = False, user = "", password = ""): RssCache.__init__(self, "cache", url, expire, user, password) self.listeners = [] self.feed = None self.messages = [] self.keywords.append("nextuid") self.keywords.append("uidvalidity") self.domain = urlparse.urlparse(url)[1] self.expire = expire self.refresh = max(self.expire, refresh) # refreshing more often than cache expire makes no sense self.force_html = force_html if not self.db.has_key("nextuid"): self.db["nextuid"] = "1" if not self.db.has_key("uidvalidity"): self.db["uidvalidity"] = str(uid) self.checkForNewMessages() self.refresh_task = task.LoopingCall(self.checkForNewMessages) self.refresh_task.start(self.refresh) def checkForNewMessages(self): self.feed = self.get_feed() # hack to filter parent's keys from DB digests = self.keywords[:] i = 1 # needs rework self.messages = [] for entry in self.feed.entries: msg_hash = self.hash.copy() if entry.has_key("id"): msg_hash.update(entry.id) else: msg_hash.update(entry.title.encode(self.feed.encoding)) digest = msg_hash.hexdigest() digests.append(digest) if not self.db.has_key(digest): # create new message uid = self.getUIDNext() self.db["nextuid"] = str(uid + 1) m = Message(entry, uid, self.feed.encoding, digest, self.domain, self.force_html) self.db[digest] = dumps((uid, m.getFlags())) else: (uid, flags) = loads(self.db[digest]) m = Message(entry, uid, self.feed.encoding, digest, self.domain, self.force_html) m.setFlags(flags) self.messages.append(m) i = i + 1 removed_messages = [k for k in self.db.keys() if k not in digests] for message in removed_messages: del self.db[message] self.messages = [k for k in self.messages if k.digest not in removed_messages] recent = reduce(lambda x,y: x + int(y.is_recent), self.messages, 0) for listener in self.listeners: if recent == 0: recent = None listener.newMessages(len(self.messages), recent) def getFlags(self): #log.msg("getFlags") return ["\\Seen", "\\Flagged", "\\Recent", "\\Deleted"] def getHierarchicalDelimiter(self): return "/" def getMessageCount(self): return len(self.messages) def getRecentCount(self): return reduce(lambda x,y: x + int(y.is_recent), self.messages, 0) def getUnseenCount(self): return reduce(lambda x,y: x + int(not y.is_seen), self.messages, 0) def getUID(self, message): return self.messages[message].getUID() def getUIDNext(self): return int(self.db["nextuid"]) def getUIDValidity(self): return int(self.db["uidvalidity"]) def isWriteable(self): return True def addListener(self, listener): self.listeners.append(listener) def removeListener(self, listener): self.listeners.remove(listener) def fetch(self, messages, uid): if not messages.last: if not uid: messages.last = self.getMessageCount() else: messages.last = self.getUIDNext() res = [] for i in messages: if not uid: res.append((i, self.messages[i-1])) else: (idx, m) = self._findUID(i) if m: res.append((idx + 1, m)) if len(res) == 0: log.err("No such messages, request was: %s %s %d" % (self.domain, str(messages), uid)) return res def store(self, messages, flags, mode, uid): res = {} if not messages.last: messages.last = self.getMessageCount() for i in messages: if not uid: message = self.messages[i-1] else: (_, message) = self._findUID(i) if not message: continue orig_flags = message.getFlags() if mode == -1: new_flags = [k for k in orig_flags if k not in flags ] elif mode == 0: new_flags = flags elif mode == 1: new_flags = flags + old_flags else: log.err("Unknown flag mode: %d" % mode) raise imap4.IllegalOperation() # filter out unknown flags new_flags = [k for k in new_flags if k in self.getFlags()] message.setFlags(new_flags) if not uid: res[str(i)] = new_flags else: res[str(message.getUID())] = new_flags self.db[message.digest] = dumps((message.getUID(), new_flags)) return res def expunge(self): return [] def requestStatus(self, names): return imap4.statusRequestHelper(self, names) def _findUID(self, uid): message = [k for k in self.messages if k.getUID() == uid] if len(message) == 1: return (self.messages.index(message[0]), message[0]) else: return (-1, None) def cleanup(self): try: self.refresh_task.stop() except: pass self.db.close() class MailboxParent: implements(imap4.IMailboxInfo) def requestStatus(self, names): return {} def getFlags(self): return [] def getHierarchicalDelimiter(self): return "/" def getMessageCount(self): return 0 def getRecentCount(self): return 0 def getUnseenCount(self): return 0 def getUIDValidity (self): return -1 def isWriteable(self): return False class Account: implements(imap4.IAccount) def __init__(self, avatar, feeds): self.avatar = avatar self.mailboxes = {} i=0 for feed in feeds: parent = feed.name.split("/")[:-1] parent_path = "" while parent: parent_path = "/".join ([parent_path, parent.pop(0)]) if (parent_path[0] == "/"): parent_path = parent_path[1:] if not self.mailboxes.has_key(parent_path): self.mailboxes[parent_path] = MailboxParent() self.mailboxes["%s" % feed.name] = Mailbox(feed.url, i, feed.timeout, feed.timeout, feed.force_html, feed.user, feed.password) i += 1 #for (k,v) in self.mailboxes.iteritems(): # #log.msg("Found mailbox: %s" % k) def addMailbox(self, name, mbox = None): #log.msg("Shall add %s" % name) raise imap4.ReadOnlyMailbox() return False def create(self, pathspec): #log.msg("Shall create %s" % pathspec) raise imap4.MailboxException() return False def select(self, name, rw=True): if self.mailboxes.has_key(name): #log.msg("Found: %s: %s" % (name, str(self.mailboxes[name]))) return self.mailboxes[name] else: log.err("could not find: %s" % name) return None def delete(self, name): return True def rename(self, oldname, newname): return True def isSubscribed(self, name): return True def subscribe(self, name): #log.msg("Want me to subscribe") return True def unsubscribe(self, name): #log.msg("Want me to unsubscribe") return True def listMailboxes(self, ref, wildcard): #log.msg("list: >%s< >%s<" % (ref, wildcard)) res = [] if len(wildcard) == 0: wildcard = "*" # check for wildcard: if string.find(wildcard, "*") < 0 and string.find(wildcard, "%") < 0: if self.mailboxes.has_key(wildcard): return [(wildcard, self.mailboxes[wildcard])] else: return [] # convert wildcard to regex pattern = string.replace(wildcard, "*", ".*") pattern = string.replace(pattern, "%", "[^/]*") #log.msg("list: pattern: ^/?%s$" % pattern) r = re.compile("^%s$" % pattern) #log.msg("pattern: ^%s$" % pattern) res = [(k,v) for k,v in self.mailboxes.iteritems() if r.match(k)] #log.msg("list: >%s< >%s< %s" % (ref, wildcard, str(res))) return res def logout(self): for _,v in self.mailboxes.iteritems(): try: v.cleanup() except AttributeError: pass class Realm: __implements__ = (portal.IRealm, ) def __init__(self, config): self.config = config def requestAvatar(self, avatarID, mind, *interfaces): #log.msg("Avatar-ID: " + str(avatarID)) if imap4.IAccount not in interfaces: raise NotImplementedError av = Account(avatarID, self.config.feeds) return imap4.IAccount, av, av.logout class AnonymousCredentials(credentials.Anonymous): def getChallenge(self): return '+' def setResponse(self, response): self.username = response def moreChallenges(self): return False class RSSIMAP4Server(imap4.IMAP4Server): def __init__(self, chal = None, contextFactory = None): c = {} c["ANONYMOUS"] = AnonymousCredentials c["PLAIN"] = imap4.PLAINCredentials c["LOGIN"] = imap4.LOGINCredentials self.IDENT = "RSSIMAP Server v0.1" imap4.IMAP4Server.__init__(self, c, contextFactory) def capabilities(self): cap = imap4.IMAP4Server.capabilities(self) cap["LIST-SUBSCRIBED"] = None cap["LISTEXT"] = None return cap class IMAPFactory(protocol.ServerFactory): protocol = RSSIMAP4Server def __init__(self, portal): self.portal = portal def buildProtocol(self, addr): p = protocol.ServerFactory.buildProtocol(self, addr) p.portal = self.portal return p def main(): # modify rsscache's USER_AGENT to reflect ourselves rsscache.USER_AGENT = "FeedMap/%s +http://jensge.org/?feedmap" % __version__ # read config file config = Config("feedmap.cfg") r = Realm(config) p = portal.Portal(r) c = checkers.InMemoryUsernamePasswordDatabaseDontUse() c.addUser("guest", "guest") p.registerChecker(c) p.registerChecker(checkers.AllowAnonymousAccess()) f = IMAPFactory(p) log.startLogging(sys.stdout) reactor.listenTCP(port = config.port, factory = f, interface = config.interface) reactor.run() if __name__ == "__main__": main()