3919bd7
[rss2email.git] /
1 #!/usr/bin/env python3
2 """rss2email: get RSS feeds emailed to you
3 http://rss2email.infogami.com
4
5 Usage:
6   new [emailaddress] (create new feedfile)
7   email newemailaddress (update default email)
8   run [--no-send] [num]
9   add feedurl [emailaddress]
10   list
11   reset
12   delete n
13   pause n
14   unpause n
15   opmlexport
16   opmlimport filename
17 """
18 __version__ = '2.71'
19 __author__ = 'Lindsey Smith (lindsey@allthingsrss.com)'
20 __copyright__ = '(C) 2004 Aaron Swartz. GNU GPL 2 or 3.'
21 ___contributors__ = [
22     'Dean Jackson',
23     'Brian Lalor',
24     'Joey Hess',
25     'Matej Cepl',
26     "Martin 'Joey' Schulze",
27     'Marcel Ackermann (http://www.DreamFlasher.de)',
28     'Lindsey Smith (maintainer)',
29     'Erik Hetzner',
30     'Aaron Swartz (original author)',
31     ]
32
33 import collections as _collections
34 import configparser as _configparser
35 from email.mime.text import MIMEText as _MIMEText
36 from email.header import Header as _Header
37 from email.utils import parseaddr as _parseaddr
38 from email.utils import formataddr as _formataddr
39 import hashlib as _hashlib
40 import os as _os
41 import pickle as _pickle
42 import smtplib as _smtplib
43 import socket as _socket
44 import subprocess as _subprocess
45 import sys as _sys
46 import threading as _threading
47 import time as _time
48 import traceback as _traceback
49 import types as _types
50 import urllib.request as _urllib_request
51 import xml.dom.minidom as _minidom
52 import xml.sax.saxutils as _saxutils
53
54 UNIX = False
55 try:
56     import fcntl as _fcntl
57     # A pox on SunOS file locking methods
58     if 'sunos' not in sys.platform:
59         UNIX = True
60 except:
61     pass
62
63 import feedparser as _feedparser
64 import html2text as _html2text
65
66
67 _urllib_request.install_opener(_urllib_request.build_opener())
68
69
70 class Config (_configparser.ConfigParser):
71     def __init__(self, **kwargs):
72         super(Config, self).__init__(dict_type=_collections.OrderedDict)
73
74
75 CONFIG = Config()
76
77 # setup defaults for feeds that don't customize
78 CONFIG['DEFAULT'] = _collections.OrderedDict((
79         ### Addressing
80         # The email address messages are from by default
81         ('from', 'bozo@dev.null.invalid'),
82         # True: Only use the 'from' address.
83         # False: Use the email address specified by the feed, when possible.
84         ('force-from', str(False)),
85         # True: Use the publisher's email if you can't find the author's.
86         # False: Just use the 'from' email instead.
87         ('use-publisher-email', str(False)),
88         # Only use the feed email address rather than friendly name
89         # plus email address
90         ('friendly-name', str(True)),
91         # Set this to override From addresses.
92         ('override-from', str(False)),
93         # Set this to default To email addresses.
94         ('to', ''),
95         # Set this to override To email addresses.
96         ('override-to', False),
97
98         ### Fetching
99         # Set an HTTP proxy (e.g. 'http://your.proxy.here:8080/')
100         ('proxy', ''),
101         # Set the timeout (in seconds) for feed server response
102         ('feed-timeout', str(60)),
103
104         ### Processing
105         # True: Generate Date header based on item's date, when possible.
106         # False: Generate Date header based on time sent.
107         ('date-header', str(False)),
108         # A comma-delimited list of some combination of
109         # ('issued', 'created', 'modified', 'expired')
110         # expressing ordered list of preference in dates
111         # to use for the Date header of the email.
112         ('date-header-order', 'modified, issued, created, expired'),
113         # Set this to add a bonus header to all emails (start with '\n').
114         # Example: bonus-header = '\nApproved: joe@bob.org'
115         ('bonus-header', ''),
116         # True: Receive one email per post.
117         # False: Receive an email every time a post changes.
118         ('trust-guid', str(True)),
119         # To most correctly encode emails with international
120         # characters, we iterate through the list below and use the
121         # first character set that works Eventually (and
122         # theoretically) UTF-8 is our catch-all failsafe.
123         ('charsets', 'US-ASCII, BIG5, ISO-2022-JP, ISO-8859-1, UTF-8'),
124         ## HTML conversion
125         # True: Send text/html messages when possible.
126         # False: Convert HTML to plain text.
127         ('html-mail', str(False)),
128         # Optional CSS styling
129         ('use-css', str(False)),
130         ('css', (
131                 'h1 {\n'
132                 '  font: 18pt Georgia, "Times New Roman";\n'
133                 '}\n'
134                 'body {\n'
135                 '  font: 12pt Arial;\n'
136                 '}\n'
137                 'a:link {\n'
138                 '  font: 12pt Arial;\n'
139                 '  font-weight: bold;\n'
140                 '  color: #0000cc;\n'
141                 '}\n'
142                 'blockquote {\n'
143                 '  font-family: monospace;\n'
144                 '}\n'
145                 '.header {\n'
146                 '  background: #e0ecff;\n'
147                 '  border-bottom: solid 4px #c3d9ff;\n'
148                 '  padding: 5px;\n'
149                 '  margin-top: 0px;\n'
150                 '  color: red;\n'
151                 '}\n'
152                 '.header a {\n'
153                 '  font-size: 20px;\n'
154                 '  text-decoration: none;\n'
155                 '}\n'
156                 '.footer {\n'
157                 '  background: #c3d9ff;\n'
158                 '  border-top: solid 4px #c3d9ff;\n'
159                 '  padding: 5px;\n'
160                 '  margin-bottom: 0px;\n'
161                 '}\n'
162                 '#entry {\n'
163                 '  border: solid 4px #c3d9ff;\n'
164                 '}\n'
165                 '#body {\n'
166                 '  margin-left: 5px;\n'
167                 '  margin-right: 5px;\n'
168                 '}\n')),
169         ## html2text options
170         # Use Unicode characters instead of their ascii psuedo-replacements
171         ('unicode-snob': str(False)),
172         # Put the links after each paragraph instead of at the end.
173         ('link-after-each-paragraph', str(False)),
174         # Wrap long lines at position. 0 for no wrapping.
175         ('body-width', str(0)),
176
177         ### Mailing
178         # True: Use SMTP_SERVER to send mail.
179         # False: Call /usr/sbin/sendmail to send mail.
180         ('use-smtp', str(False)),
181         ('smtp-server', 'smtp.yourisp.net:25'),
182         ('smtp-auth', str(False)),      # set to True to use SMTP AUTH
183         ('smtp-username', 'username'),  # username for SMTP AUTH
184         ('smtp-password', 'password'),  # password for SMTP AUTH
185         ('smtp-ssl', str(False)),       # Connect to the SMTP server using SSL
186
187         ### Miscellaneous
188         # Verbosity (one of 'error', 'warning', 'info', or 'debug').
189         ('verbose', 'warning'),
190         ))
191
192
193 def send(sender, recipient, subject, body, contenttype, extraheaders=None, smtpserver=None):
194     """Send an email.
195
196     All arguments should be Unicode strings (plain ASCII works as well).
197
198     Only the real name part of sender and recipient addresses may contain
199     non-ASCII characters.
200
201     The email will be properly MIME encoded and delivered though SMTP to
202     localhost port 25.  This is easy to change if you want something different.
203
204     The charset of the email will be the first one out of the list
205     that can represent all the characters occurring in the email.
206     """
207
208     # Header class is smart enough to try US-ASCII, then the charset we
209     # provide, then fall back to UTF-8.
210     header_charset = 'ISO-8859-1'
211
212     # We must choose the body charset manually
213     for body_charset in CHARSET_LIST:
214         try:
215             body.encode(body_charset)
216         except (UnicodeError, LookupError):
217             pass
218         else:
219             break
220
221     # Split real name (which is optional) and email address parts
222     sender_name, sender_addr = parseaddr(sender)
223     recipient_name, recipient_addr = parseaddr(recipient)
224
225     # We must always pass Unicode strings to Header, otherwise it will
226     # use RFC 2047 encoding even on plain ASCII strings.
227     sender_name = str(Header(unicode(sender_name), header_charset))
228     recipient_name = str(Header(unicode(recipient_name), header_charset))
229
230     # Make sure email addresses do not contain non-ASCII characters
231     sender_addr = sender_addr.encode('ascii')
232     recipient_addr = recipient_addr.encode('ascii')
233
234     # Create the message ('plain' stands for Content-Type: text/plain)
235     msg = MIMEText(body.encode(body_charset), contenttype, body_charset)
236     msg['To'] = formataddr((recipient_name, recipient_addr))
237     msg['Subject'] = Header(unicode(subject), header_charset)
238     for hdr in extraheaders.keys():
239         try:
240             msg[hdr] = Header(unicode(extraheaders[hdr], header_charset))
241         except:
242             msg[hdr] = Header(extraheaders[hdr])
243
244     fromhdr = formataddr((sender_name, sender_addr))
245     msg['From'] = fromhdr
246
247     msg_as_string = msg.as_string()
248 #DEPRECATED     if QP_REQUIRED:
249 #DEPRECATED         ins, outs = SIO(msg_as_string), SIO()
250 #DEPRECATED         mimify.mimify(ins, outs)
251 #DEPRECATED         msg_as_string = outs.getvalue()
252
253     if SMTP_SEND:
254         if not smtpserver:
255             try:
256                 if SMTP_SSL:
257                     smtpserver = smtplib.SMTP_SSL()
258                 else:
259                     smtpserver = smtplib.SMTP()
260                 smtpserver.connect(SMTP_SERVER)
261             except KeyboardInterrupt:
262                 raise
263             except Exception, e:
264                 print >>warn, ""
265                 print >>warn, ('Fatal error: could not connect to mail server "%s"' % SMTP_SERVER)
266                 print >>warn, ('Check your config.py file to confirm that SMTP_SERVER and other mail server settings are configured properly')
267                 if hasattr(e, 'reason'):
268                     print >>warn, "Reason:", e.reason
269                 sys.exit(1)
270
271             if AUTHREQUIRED:
272                 try:
273                     smtpserver.ehlo()
274                     if not SMTP_SSL: smtpserver.starttls()
275                     smtpserver.ehlo()
276                     smtpserver.login(SMTP_USER, SMTP_PASS)
277                 except KeyboardInterrupt:
278                     raise
279                 except Exception, e:
280                     print >>warn, ""
281                     print >>warn, ('Fatal error: could not authenticate with mail server "%s" as user "%s"' % (SMTP_SERVER, SMTP_USER))
282                     print >>warn, ('Check your config.py file to confirm that SMTP_SERVER and other mail server settings are configured properly')
283                     if hasattr(e, 'reason'):
284                         print >>warn, "Reason:", e.reason
285                     sys.exit(1)
286
287         smtpserver.sendmail(sender, recipient, msg_as_string)
288         return smtpserver
289
290     else:
291         try:
292             p = subprocess.Popen(["/usr/sbin/sendmail", recipient], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
293             p.communicate(msg_as_string)
294             status = p.returncode
295             assert status != None, "just a sanity check"
296             if status != 0:
297                 print >>warn, ""
298                 print >>warn, ('Fatal error: sendmail exited with code %s' % status)
299                 sys.exit(1)
300         except:
301             print '''Error attempting to send email via sendmail. Possibly you need to configure your config.py to use a SMTP server? Please refer to the rss2email documentation or website (http://rss2email.infogami.com) for complete documentation of config.py. The options below may suffice for configuring email:
302 # 1: Use SMTP_SERVER to send mail.
303 # 0: Call /usr/sbin/sendmail to send mail.
304 SMTP_SEND = 0
305
306 SMTP_SERVER = "smtp.yourisp.net:25"
307 AUTHREQUIRED = 0 # if you need to use SMTP AUTH set to 1
308 SMTP_USER = 'username'  # for SMTP AUTH, set SMTP username here
309 SMTP_PASS = 'password'  # for SMTP AUTH, set SMTP password here
310 '''
311             sys.exit(1)
312         return None
313
314 ### Load the Options ###
315
316 # Read options from config file if present.
317 sys.path.insert(0,".")
318 try:
319     from config import *
320 except:
321     pass
322
323 warn = sys.stderr
324
325 if QP_REQUIRED:
326     print >>warn, "QP_REQUIRED has been deprecated in rss2email."
327
328 socket_errors = []
329 for e in ['error', 'gaierror']:
330     if hasattr(socket, e): socket_errors.append(getattr(socket, e))
331
332 feedparser.USER_AGENT = "rss2email/"+__version__+ " +http://www.allthingsrss.com/rss2email/"
333
334
335 h2t.UNICODE_SNOB = UNICODE_SNOB
336 h2t.LINKS_EACH_PARAGRAPH = LINKS_EACH_PARAGRAPH
337 h2t.BODY_WIDTH = BODY_WIDTH
338 html2text = h2t.html2text
339
340 ### Utility Functions ###
341
342 class TimeoutError(Exception): pass
343
344 class InputError(Exception): pass
345
346 def timelimit(timeout, function):
347 #    def internal(function):
348         def internal2(*args, **kw):
349             """
350             from http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/473878
351             """
352             class Calculator(threading.Thread):
353                 def __init__(self):
354                     threading.Thread.__init__(self)
355                     self.result = None
356                     self.error = None
357
358                 def run(self):
359                     try:
360                         self.result = function(*args, **kw)
361                     except:
362                         self.error = sys.exc_info()
363
364             c = Calculator()
365             c.setDaemon(True) # don't hold up exiting
366             c.start()
367             c.join(timeout)
368             if c.isAlive():
369                 raise TimeoutError
370             if c.error:
371                 raise c.error[0], c.error[1]
372             return c.result
373         return internal2
374 #    return internal
375
376
377 def isstr(f): return isinstance(f, type('')) or isinstance(f, type(u''))
378 def ishtml(t): return type(t) is type(())
379 def contains(a,b): return a.find(b) != -1
380 def unu(s): # I / freakin' hate / that unicode
381     if type(s) is types.UnicodeType: return s.encode('utf-8')
382     else: return s
383
384 ### Parsing Utilities ###
385
386 def getContent(entry, HTMLOK=0):
387     """Select the best content from an entry, deHTMLizing if necessary.
388     If raw HTML is best, an ('HTML', best) tuple is returned. """
389
390     # How this works:
391     #  * We have a bunch of potential contents.
392     #  * We go thru looking for our first choice.
393     #    (HTML or text, depending on HTMLOK)
394     #  * If that doesn't work, we go thru looking for our second choice.
395     #  * If that still doesn't work, we just take the first one.
396     #
397     # Possible future improvement:
398     #  * Instead of just taking the first one
399     #    pick the one in the "best" language.
400     #  * HACK: hardcoded HTMLOK, should take a tuple of media types
401
402     conts = entry.get('content', [])
403
404     if entry.get('summary_detail', {}):
405         conts += [entry.summary_detail]
406
407     if conts:
408         if HTMLOK:
409             for c in conts:
410                 if contains(c.type, 'html'): return ('HTML', c.value)
411
412         if not HTMLOK: # Only need to convert to text if HTML isn't OK
413             for c in conts:
414                 if contains(c.type, 'html'):
415                     return html2text(c.value)
416
417         for c in conts:
418             if c.type == 'text/plain': return c.value
419
420         return conts[0].value
421
422     return ""
423
424 def getID(entry):
425     """Get best ID from an entry."""
426     if TRUST_GUID:
427         if 'id' in entry and entry.id:
428             # Newer versions of feedparser could return a dictionary
429             if type(entry.id) is DictType:
430                 return entry.id.values()[0]
431
432             return entry.id
433
434     content = getContent(entry)
435     if content and content != "\n": return hash(unu(content)).hexdigest()
436     if 'link' in entry: return entry.link
437     if 'title' in entry: return hash(unu(entry.title)).hexdigest()
438
439 def getName(r, entry):
440     """Get the best name."""
441
442     if NO_FRIENDLY_NAME: return ''
443
444     feed = r.feed
445     if hasattr(r, "url") and r.url in OVERRIDE_FROM.keys():
446         return OVERRIDE_FROM[r.url]
447
448     name = feed.get('title', '')
449
450     if 'name' in entry.get('author_detail', []): # normally {} but py2.1
451         if entry.author_detail.name:
452             if name: name += ": "
453             det=entry.author_detail.name
454             try:
455                 name +=  entry.author_detail.name
456             except UnicodeDecodeError:
457                 name +=  unicode(entry.author_detail.name, 'utf-8')
458
459     elif 'name' in feed.get('author_detail', []):
460         if feed.author_detail.name:
461             if name: name += ", "
462             name += feed.author_detail.name
463
464     return name
465
466 def validateEmail(email, planb):
467     """Do a basic quality check on email address, but return planb if email doesn't appear to be well-formed"""
468     email_parts = email.split('@')
469     if len(email_parts) != 2:
470         return planb
471     return email
472
473 def getEmail(r, entry):
474     """Get the best email_address. If the best guess isn't well-formed (something@somthing.com), use DEFAULT_FROM instead"""
475
476     feed = r.feed
477
478     if FORCE_FROM: return DEFAULT_FROM
479
480     if hasattr(r, "url") and r.url in OVERRIDE_EMAIL.keys():
481         return validateEmail(OVERRIDE_EMAIL[r.url], DEFAULT_FROM)
482
483     if 'email' in entry.get('author_detail', []):
484         return validateEmail(entry.author_detail.email, DEFAULT_FROM)
485
486     if 'email' in feed.get('author_detail', []):
487         return validateEmail(feed.author_detail.email, DEFAULT_FROM)
488
489     if USE_PUBLISHER_EMAIL:
490         if 'email' in feed.get('publisher_detail', []):
491             return validateEmail(feed.publisher_detail.email, DEFAULT_FROM)
492
493         if feed.get("errorreportsto", ''):
494             return validateEmail(feed.errorreportsto, DEFAULT_FROM)
495
496     if hasattr(r, "url") and r.url in DEFAULT_EMAIL.keys():
497         return DEFAULT_EMAIL[r.url]
498     return DEFAULT_FROM
499
500 ### Simple Database of Feeds ###
501
502 class Feed:
503     def __init__(self, url, to):
504         self.url, self.etag, self.modified, self.seen = url, None, None, {}
505         self.active = True
506         self.to = to
507
508 def load(lock=1):
509     if not os.path.exists(feedfile):
510         print 'Feedfile "%s" does not exist.  If you\'re using r2e for the first time, you' % feedfile
511         print "have to run 'r2e new' first."
512         sys.exit(1)
513     try:
514         feedfileObject = open(feedfile, 'r')
515     except IOError, e:
516         print "Feedfile could not be opened: %s" % e
517         sys.exit(1)
518     feeds = pickle.load(feedfileObject)
519
520     if lock:
521         locktype = 0
522         if unix:
523             locktype = fcntl.LOCK_EX
524             fcntl.flock(feedfileObject.fileno(), locktype)
525         #HACK: to deal with lock caching
526         feedfileObject = open(feedfile, 'r')
527         feeds = pickle.load(feedfileObject)
528         if unix:
529             fcntl.flock(feedfileObject.fileno(), locktype)
530     if feeds:
531         for feed in feeds[1:]:
532             if not hasattr(feed, 'active'):
533                 feed.active = True
534
535     return feeds, feedfileObject
536
537 def unlock(feeds, feedfileObject):
538     if not unix:
539         pickle.dump(feeds, open(feedfile, 'w'))
540     else:
541         fd = open(feedfile+'.tmp', 'w')
542         pickle.dump(feeds, fd)
543         fd.flush()
544         os.fsync(fd.fileno())
545         fd.close()
546         os.rename(feedfile+'.tmp', feedfile)
547         fcntl.flock(feedfileObject.fileno(), fcntl.LOCK_UN)
548
549 #@timelimit(FEED_TIMEOUT)
550 def parse(url, etag, modified):
551     if PROXY == '':
552         return feedparser.parse(url, etag, modified)
553     else:
554         proxy = urllib2.ProxyHandler( {"http":PROXY} )
555         return feedparser.parse(url, etag, modified, handlers = [proxy])
556
557
558 ### Program Functions ###
559
560 def add(*args):
561     if len(args) == 2 and contains(args[1], '@') and not contains(args[1], '://'):
562         urls, to = [args[0]], args[1]
563     else:
564         urls, to = args, None
565
566     feeds, feedfileObject = load()
567     if (feeds and not isstr(feeds[0]) and to is None) or (not len(feeds) and to is None):
568         print "No email address has been defined. Please run 'r2e email emailaddress' or"
569         print "'r2e add url emailaddress'."
570         sys.exit(1)
571     for url in urls: feeds.append(Feed(url, to))
572     unlock(feeds, feedfileObject)
573
574 def run(num=None):
575     feeds, feedfileObject = load()
576     smtpserver = None
577     try:
578         # We store the default to address as the first item in the feeds list.
579         # Here we take it out and save it for later.
580         default_to = ""
581         if feeds and isstr(feeds[0]): default_to = feeds[0]; ifeeds = feeds[1:]
582         else: ifeeds = feeds
583
584         if num: ifeeds = [feeds[num]]
585         feednum = 0
586
587         for f in ifeeds:
588             try:
589                 feednum += 1
590                 if not f.active: continue
591
592                 if VERBOSE: print >>warn, 'I: Processing [%d] "%s"' % (feednum, f.url)
593                 r = {}
594                 try:
595                     r = timelimit(FEED_TIMEOUT, parse)(f.url, f.etag, f.modified)
596                 except TimeoutError:
597                     print >>warn, 'W: feed [%d] "%s" timed out' % (feednum, f.url)
598                     continue
599
600                 # Handle various status conditions, as required
601                 if 'status' in r:
602                     if r.status == 301: f.url = r['url']
603                     elif r.status == 410:
604                         print >>warn, "W: feed gone; deleting", f.url
605                         feeds.remove(f)
606                         continue
607
608                 http_status = r.get('status', 200)
609                 if VERBOSE > 1: print >>warn, "I: http status", http_status
610                 http_headers = r.get('headers', {
611                   'content-type': 'application/rss+xml',
612                   'content-length':'1'})
613                 exc_type = r.get("bozo_exception", Exception()).__class__
614                 if http_status != 304 and not r.entries and not r.get('version', ''):
615                     if http_status not in [200, 302]:
616                         print >>warn, "W: error %d [%d] %s" % (http_status, feednum, f.url)
617
618                     elif contains(http_headers.get('content-type', 'rss'), 'html'):
619                         print >>warn, "W: looks like HTML [%d] %s"  % (feednum, f.url)
620
621                     elif http_headers.get('content-length', '1') == '0':
622                         print >>warn, "W: empty page [%d] %s" % (feednum, f.url)
623
624                     elif hasattr(socket, 'timeout') and exc_type == socket.timeout:
625                         print >>warn, "W: timed out on [%d] %s" % (feednum, f.url)
626
627                     elif exc_type == IOError:
628                         print >>warn, 'W: "%s" [%d] %s' % (r.bozo_exception, feednum, f.url)
629
630                     elif hasattr(feedparser, 'zlib') and exc_type == feedparser.zlib.error:
631                         print >>warn, "W: broken compression [%d] %s" % (feednum, f.url)
632
633                     elif exc_type in socket_errors:
634                         exc_reason = r.bozo_exception.args[1]
635                         print >>warn, "W: %s [%d] %s" % (exc_reason, feednum, f.url)
636
637                     elif exc_type == urllib2.URLError:
638                         if r.bozo_exception.reason.__class__ in socket_errors:
639                             exc_reason = r.bozo_exception.reason.args[1]
640                         else:
641                             exc_reason = r.bozo_exception.reason
642                         print >>warn, "W: %s [%d] %s" % (exc_reason, feednum, f.url)
643
644                     elif exc_type == AttributeError:
645                         print >>warn, "W: %s [%d] %s" % (r.bozo_exception, feednum, f.url)
646
647                     elif exc_type == KeyboardInterrupt:
648                         raise r.bozo_exception
649
650                     elif r.bozo:
651                         print >>warn, 'E: error in [%d] "%s" feed (%s)' % (feednum, f.url, r.get("bozo_exception", "can't process"))
652
653                     else:
654                         print >>warn, "=== rss2email encountered a problem with this feed ==="
655                         print >>warn, "=== See the rss2email FAQ at http://www.allthingsrss.com/rss2email/ for assistance ==="
656                         print >>warn, "=== If this occurs repeatedly, send this to lindsey@allthingsrss.com ==="
657                         print >>warn, "E:", r.get("bozo_exception", "can't process"), f.url
658                         print >>warn, r
659                         print >>warn, "rss2email", __version__
660                         print >>warn, "feedparser", feedparser.__version__
661                         print >>warn, "html2text", h2t.__version__
662                         print >>warn, "Python", sys.version
663                         print >>warn, "=== END HERE ==="
664                     continue
665
666                 r.entries.reverse()
667
668                 for entry in r.entries:
669                     id = getID(entry)
670
671                     # If TRUST_GUID isn't set, we get back hashes of the content.
672                     # Instead of letting these run wild, we put them in context
673                     # by associating them with the actual ID (if it exists).
674
675                     frameid = entry.get('id')
676                     if not(frameid): frameid = id
677                     if type(frameid) is DictType:
678                         frameid = frameid.values()[0]
679
680                     # If this item's ID is in our database
681                     # then it's already been sent
682                     # and we don't need to do anything more.
683
684                     if frameid in f.seen:
685                         if f.seen[frameid] == id: continue
686
687                     if not (f.to or default_to):
688                         print "No default email address defined. Please run 'r2e email emailaddress'"
689                         print "Ignoring feed %s" % f.url
690                         break
691
692                     if 'title_detail' in entry and entry.title_detail:
693                         title = entry.title_detail.value
694                         if contains(entry.title_detail.type, 'html'):
695                             title = html2text(title)
696                     else:
697                         title = getContent(entry)[:70]
698
699                     title = title.replace("\n", " ").strip()
700
701                     datetime = time.gmtime()
702
703                     if DATE_HEADER:
704                         for datetype in DATE_HEADER_ORDER:
705                             kind = datetype+"_parsed"
706                             if kind in entry and entry[kind]: datetime = entry[kind]
707
708                     link = entry.get('link', "")
709
710                     from_addr = getEmail(r, entry)
711
712                     name = h2t.unescape(getName(r, entry))
713                     fromhdr = formataddr((name, from_addr,))
714                     tohdr = (f.to or default_to)
715                     subjecthdr = title
716                     datehdr = time.strftime("%a, %d %b %Y %H:%M:%S -0000", datetime)
717                     useragenthdr = "rss2email"
718
719                     # Add post tags, if available
720                     tagline = ""
721                     if 'tags' in entry:
722                         tags = entry.get('tags')
723                         taglist = []
724                         if tags:
725                             for tag in tags:
726                                 taglist.append(tag['term'])
727                         if taglist:
728                             tagline = ",".join(taglist)
729
730                     extraheaders = {'Date': datehdr, 'User-Agent': useragenthdr, 'X-RSS-Feed': f.url, 'X-RSS-ID': id, 'X-RSS-URL': link, 'X-RSS-TAGS' : tagline}
731                     if BONUS_HEADER != '':
732                         for hdr in BONUS_HEADER.strip().splitlines():
733                             pos = hdr.strip().find(':')
734                             if pos > 0:
735                                 extraheaders[hdr[:pos]] = hdr[pos+1:].strip()
736                             else:
737                                 print >>warn, "W: malformed BONUS HEADER", BONUS_HEADER
738
739                     entrycontent = getContent(entry, HTMLOK=HTML_MAIL)
740                     contenttype = 'plain'
741                     content = ''
742                     if USE_CSS_STYLING and HTML_MAIL:
743                         contenttype = 'html'
744                         content = "<html>\n"
745                         content += '<head><style><!--' + STYLE_SHEET + '//--></style></head>\n'
746                         content += '<body>\n'
747                         content += '<div id="entry">\n'
748                         content += '<h1'
749                         content += ' class="header"'
750                         content += '><a href="'+link+'">'+subjecthdr+'</a></h1>\n'
751                         if ishtml(entrycontent):
752                             body = entrycontent[1].strip()
753                         else:
754                             body = entrycontent.strip()
755                         if body != '':
756                             content += '<div id="body"><table><tr><td>\n' + body + '</td></tr></table></div>\n'
757                         content += '\n<p class="footer">URL: <a href="'+link+'">'+link+'</a>'
758                         if hasattr(entry,'enclosures'):
759                             for enclosure in entry.enclosures:
760                                 if (hasattr(enclosure, 'url') and enclosure.url != ""):
761                                     content += ('<br/>Enclosure: <a href="'+enclosure.url+'">'+enclosure.url+"</a>\n")
762                                 if (hasattr(enclosure, 'src') and enclosure.src != ""):
763                                     content += ('<br/>Enclosure: <a href="'+enclosure.src+'">'+enclosure.src+'</a><br/><img src="'+enclosure.src+'"\n')
764                         if 'links' in entry:
765                             for extralink in entry.links:
766                                 if ('rel' in extralink) and extralink['rel'] == u'via':
767                                     extraurl = extralink['href']
768                                     extraurl = extraurl.replace('http://www.google.com/reader/public/atom/', 'http://www.google.com/reader/view/')
769                                     viatitle = extraurl
770                                     if ('title' in extralink):
771                                         viatitle = extralink['title']
772                                     content += '<br/>Via: <a href="'+extraurl+'">'+viatitle+'</a>\n'
773                         content += '</p></div>\n'
774                         content += "\n\n</body></html>"
775                     else:
776                         if ishtml(entrycontent):
777                             contenttype = 'html'
778                             content = "<html>\n"
779                             content = ("<html><body>\n\n" +
780                                        '<h1><a href="'+link+'">'+subjecthdr+'</a></h1>\n\n' +
781                                        entrycontent[1].strip() + # drop type tag (HACK: bad abstraction)
782                                        '<p>URL: <a href="'+link+'">'+link+'</a></p>' )
783
784                             if hasattr(entry,'enclosures'):
785                                 for enclosure in entry.enclosures:
786                                     if enclosure.url != "":
787                                         content += ('Enclosure: <a href="'+enclosure.url+'">'+enclosure.url+"</a><br/>\n")
788                             if 'links' in entry:
789                                 for extralink in entry.links:
790                                     if ('rel' in extralink) and extralink['rel'] == u'via':
791                                         content += 'Via: <a href="'+extralink['href']+'">'+extralink['title']+'</a><br/>\n'
792
793                             content += ("\n</body></html>")
794                         else:
795                             content = entrycontent.strip() + "\n\nURL: "+link
796                             if hasattr(entry,'enclosures'):
797                                 for enclosure in entry.enclosures:
798                                     if enclosure.url != "":
799                                         content += ('\nEnclosure: ' + enclosure.url + "\n")
800                             if 'links' in entry:
801                                 for extralink in entry.links:
802                                     if ('rel' in extralink) and extralink['rel'] == u'via':
803                                         content += '<a href="'+extralink['href']+'">Via: '+extralink['title']+'</a>\n'
804
805                     smtpserver = send(fromhdr, tohdr, subjecthdr, content, contenttype, extraheaders, smtpserver)
806
807                     f.seen[frameid] = id
808
809                 f.etag, f.modified = r.get('etag', None), r.get('modified', None)
810             except (KeyboardInterrupt, SystemExit):
811                 raise
812             except:
813                 print >>warn, "=== rss2email encountered a problem with this feed ==="
814                 print >>warn, "=== See the rss2email FAQ at http://www.allthingsrss.com/rss2email/ for assistance ==="
815                 print >>warn, "=== If this occurs repeatedly, send this to lindsey@allthingsrss.com ==="
816                 print >>warn, "E: could not parse", f.url
817                 traceback.print_exc(file=warn)
818                 print >>warn, "rss2email", __version__
819                 print >>warn, "feedparser", feedparser.__version__
820                 print >>warn, "html2text", h2t.__version__
821                 print >>warn, "Python", sys.version
822                 print >>warn, "=== END HERE ==="
823                 continue
824
825     finally:
826         unlock(feeds, feedfileObject)
827         if smtpserver:
828             smtpserver.quit()
829
830 def list():
831     feeds, feedfileObject = load(lock=0)
832     default_to = ""
833
834     if feeds and isstr(feeds[0]):
835         default_to = feeds[0]; ifeeds = feeds[1:]; i=1
836         print "default email:", default_to
837     else: ifeeds = feeds; i = 0
838     for f in ifeeds:
839         active = ('[ ]', '[*]')[f.active]
840         print `i`+':',active, f.url, '('+(f.to or ('default: '+default_to))+')'
841         if not (f.to or default_to):
842             print "   W: Please define a default address with 'r2e email emailaddress'"
843         i+= 1
844
845 def opmlexport():
846     feeds, feedfileObject = load(lock=0)
847
848     if feeds:
849         print '<?xml version="1.0" encoding="UTF-8"?>\n<opml version="1.0">\n<head>\n<title>rss2email OPML export</title>\n</head>\n<body>'
850         for f in feeds[1:]:
851             url = xml.sax.saxutils.escape(f.url)
852             print '<outline type="rss" text="%s" xmlUrl="%s"/>' % (url, url)
853         print '</body>\n</opml>'
854
855 def opmlimport(importfile):
856     importfileObject = None
857     print 'Importing feeds from', importfile
858     if not os.path.exists(importfile):
859         print 'OPML import file "%s" does not exist.' % feedfile
860     try:
861         importfileObject = open(importfile, 'r')
862     except IOError, e:
863         print "OPML import file could not be opened: %s" % e
864         sys.exit(1)
865     try:
866         dom = xml.dom.minidom.parse(importfileObject)
867         newfeeds = dom.getElementsByTagName('outline')
868     except:
869         print 'E: Unable to parse OPML file'
870         sys.exit(1)
871
872     feeds, feedfileObject = load(lock=1)
873
874     for f in newfeeds:
875         if f.hasAttribute('xmlUrl'):
876             feedurl = f.getAttribute('xmlUrl')
877             print 'Adding %s' % xml.sax.saxutils.unescape(feedurl)
878             feeds.append(Feed(feedurl, None))
879
880     unlock(feeds, feedfileObject)
881
882 def delete(n):
883     feeds, feedfileObject = load()
884     if (n == 0) and (feeds and isstr(feeds[0])):
885         print >>warn, "W: ID has to be equal to or higher than 1"
886     elif n >= len(feeds):
887         print >>warn, "W: no such feed"
888     else:
889         print >>warn, "W: deleting feed %s" % feeds[n].url
890         feeds = feeds[:n] + feeds[n+1:]
891         if n != len(feeds):
892             print >>warn, "W: feed IDs have changed, list before deleting again"
893     unlock(feeds, feedfileObject)
894
895 def toggleactive(n, active):
896     feeds, feedfileObject = load()
897     if (n == 0) and (feeds and isstr(feeds[0])):
898         print >>warn, "W: ID has to be equal to or higher than 1"
899     elif n >= len(feeds):
900         print >>warn, "W: no such feed"
901     else:
902         action = ('Pausing', 'Unpausing')[active]
903         print >>warn, "%s feed %s" % (action, feeds[n].url)
904         feeds[n].active = active
905     unlock(feeds, feedfileObject)
906
907 def reset():
908     feeds, feedfileObject = load()
909     if feeds and isstr(feeds[0]):
910         ifeeds = feeds[1:]
911     else: ifeeds = feeds
912     for f in ifeeds:
913         if VERBOSE: print "Resetting %d already seen items" % len(f.seen)
914         f.seen = {}
915         f.etag = None
916         f.modified = None
917
918     unlock(feeds, feedfileObject)
919
920 def email(addr):
921     feeds, feedfileObject = load()
922     if feeds and isstr(feeds[0]): feeds[0] = addr
923     else: feeds = [addr] + feeds
924     unlock(feeds, feedfileObject)
925
926 if __name__ == '__main__':
927     args = sys.argv
928     try:
929         if len(args) < 3: raise InputError, "insufficient args"
930         feedfile, action, args = args[1], args[2], args[3:]
931
932         if action == "run":
933             if args and args[0] == "--no-send":
934                 def send(sender, recipient, subject, body, contenttype, extraheaders=None, smtpserver=None):
935                     if VERBOSE: print 'Not sending:', unu(subject)
936
937             if args and args[-1].isdigit(): run(int(args[-1]))
938             else: run()
939
940         elif action == "email":
941             if not args:
942                 raise InputError, "Action '%s' requires an argument" % action
943             else:
944                 email(args[0])
945
946         elif action == "add": add(*args)
947
948         elif action == "new":
949             if len(args) == 1: d = [args[0]]
950             else: d = []
951             pickle.dump(d, open(feedfile, 'w'))
952
953         elif action == "list": list()
954
955         elif action in ("help", "--help", "-h"): print __doc__
956
957         elif action == "delete":
958             if not args:
959                 raise InputError, "Action '%s' requires an argument" % action
960             elif args[0].isdigit():
961                 delete(int(args[0]))
962             else:
963                 raise InputError, "Action '%s' requires a number as its argument" % action
964
965         elif action in ("pause", "unpause"):
966             if not args:
967                 raise InputError, "Action '%s' requires an argument" % action
968             elif args[0].isdigit():
969                 active = (action == "unpause")
970                 toggleactive(int(args[0]), active)
971             else:
972                 raise InputError, "Action '%s' requires a number as its argument" % action
973
974         elif action == "reset": reset()
975
976         elif action == "opmlexport": opmlexport()
977
978         elif action == "opmlimport":
979             if not args:
980                 raise InputError, "OPML import '%s' requires a filename argument" % action
981             opmlimport(args[0])
982
983         else:
984             raise InputError, "Invalid action"
985
986     except InputError, e:
987         print "E:", e
988         print
989         print __doc__