changeset 2205:90ef29715d9e

merged main
author Thomas Waldmann <tw AT waldmann-edv DOT de>
date Tue, 15 Oct 2013 21:04:06 -0700
parents c574f206f39f (diff) ed73c69d80c0 (current diff)
children 85840f06e1ac
files
diffstat 42 files changed, 1723 insertions(+), 165 deletions(-) [+]
line wrap: on
line diff
--- a/MoinMoin/_tests/test_user.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/_tests/test_user.py	Tue Oct 15 21:04:06 2013 -0700
@@ -12,6 +12,8 @@
 from flask import g as flaskg
 
 from MoinMoin import user
+from MoinMoin.items import Item
+from MoinMoin.constants.keys import (ITEMID, NAME, NAMEPREFIX, NAMERE, NAMESPACE, TAGS)
 
 
 class TestSimple(object):
@@ -119,28 +121,36 @@
 
     # Subscriptions ---------------------------------------------------
 
-    def testSubscriptionSubscribedPage(self):
-        """ user: tests is_subscribed_to  """
-        pagename = u'HelpMiscellaneous'
-        name = u'__Jürgen Herman__'
-        password = name
-        self.createUser(name, password)
-        # Login - this should replace the old password in the user file
-        theUser = user.User(name=name, password=password)
-        theUser.subscribe(pagename)
-        assert theUser.is_subscribed_to([pagename])  # list(!) of pages to check
+    def test_subscriptions(self):
+        pagename = u"Foo:foo 123"
+        tagname = u"xxx"
+        regexp = r"\d+"
+        item = Item.create(pagename)
+        item._save({NAMESPACE: u"", TAGS: [tagname]})
+        item = Item.create(pagename)
+        meta = item.meta
 
-    def testSubscriptionSubPage(self):
-        """ user: tests is_subscribed_to on a subpage """
-        pagename = u'HelpMiscellaneous'
-        testPagename = u'HelpMiscellaneous/FrequentlyAskedQuestions'
-        name = u'__Jürgen Herman__'
+        name = u'bar'
         password = name
-        self.createUser(name, password)
-        # Login - this should replace the old password in the user file
-        theUser = user.User(name=name, password=password)
-        theUser.subscribe(pagename)
-        assert not theUser.is_subscribed_to([testPagename])  # list(!) of pages to check
+        email = "bar@example.org"
+        user.create_user(name, password, email)
+        the_user = user.User(name=name, password=password)
+        assert not the_user.is_subscribed_to(item)
+        the_user.subscribe(NAME, u"SomeOtherPageName", u"")
+        result = the_user.unsubscribe(NAME, u"OneMorePageName", u"")
+        assert result is False
+
+        subscriptions = [(ITEMID, meta[ITEMID], None),
+                         (NAME, pagename, meta[NAMESPACE]),
+                         (TAGS, tagname, meta[NAMESPACE]),
+                         (NAMEPREFIX, pagename[:4], meta[NAMESPACE]),
+                         (NAMERE, regexp, meta[NAMESPACE])]
+        for subscription in subscriptions:
+            keyword, value, namespace = subscription
+            the_user.subscribe(keyword, value, namespace)
+            assert the_user.is_subscribed_to(item)
+            the_user.unsubscribe(keyword, value, namespace, item)
+            assert not the_user.is_subscribed_to(item)
 
     # Bookmarks -------------------------------------------------------
 
--- a/MoinMoin/apps/frontend/_tests/test_frontend.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/apps/frontend/_tests/test_frontend.py	Tue Oct 15 21:04:06 2013 -0700
@@ -172,7 +172,7 @@
         self._test_view('frontend.quicklink_item', status='302 FOUND', viewopts=dict(item_name='DoesntExist'), data=['<!DOCTYPE HTML'])
 
     def test_subscribe_item(self):
-        self._test_view('frontend.subscribe_item', status='302 FOUND', viewopts=dict(item_name='DoesntExist'), data=['<!DOCTYPE HTML'])
+        self._test_view('frontend.subscribe_item', status='404 NOT FOUND', viewopts=dict(item_name='DoesntExist'))
 
     def test_register(self):
         self._test_view('frontend.register')
--- a/MoinMoin/apps/frontend/views.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/apps/frontend/views.py	Tue Oct 15 21:04:06 2013 -0700
@@ -46,8 +46,9 @@
 from MoinMoin.i18n import _, L_, N_
 from MoinMoin.themes import render_template, contenttype_to_class
 from MoinMoin.apps.frontend import frontend
-from MoinMoin.forms import (OptionalText, RequiredText, URL, YourOpenID, YourEmail, RequiredPassword, Checkbox,
-                            InlineCheckbox, Select, Names, Tags, Natural, Hidden, MultiSelect, Enum)
+from MoinMoin.forms import (OptionalText, RequiredText, URL, YourOpenID, YourEmail,
+                            RequiredPassword, Checkbox, InlineCheckbox, Select, Names,
+                            Tags, Natural, Hidden, MultiSelect, Enum, Subscriptions)
 from MoinMoin.items import BaseChangeForm, Item, NonExistent, NameNotUniqueError
 from MoinMoin.items.content import content_registry
 from MoinMoin import user, util
@@ -361,8 +362,7 @@
 @frontend.route('/+show/+<rev>/<itemname:item_name>', methods=['GET'])
 def show_item(item_name, rev):
     flaskg.user.add_trail(item_name)
-    item_displayed.send(app._get_current_object(),
-                        item_name=item_name)
+    item_displayed.send(app, item_name=item_name)
     try:
         item = Item.create(item_name, rev_id=rev)
         result = item.do_show(rev)
@@ -438,8 +438,7 @@
 @frontend.route('/+content/<itemname:item_name>', defaults=dict(rev=CURRENT))
 def content_item(item_name, rev):
     """ same as show_item, but we only show the content """
-    item_displayed.send(app._get_current_object(),
-                        item_name=item_name)
+    item_displayed.send(app, item_name=item_name)
     try:
         item = Item.create(item_name, rev_id=rev)
     except AccessDenied:
@@ -737,7 +736,7 @@
         item = Item.create(item_name)
         revid, size = item.modify({}, data, contenttype_guessed=contenttype)
         item_modified.send(app._get_current_object(),
-                           item_name=item_name)
+                           item_name=item_name, action=ACTION_SAVE)
         return jsonify(name=subitem_name,
                        size=size,
                        url=url_for('.show_item', item_name=item_name, rev=revid),
@@ -890,8 +889,13 @@
     :type item_name: unicode
     :returns: a page with all the items which link or transclude item_name
     """
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
     refs_here = _backrefs(item_name)
     return render_template('link_list_item_panel.html',
+                           item=item,
                            item_name=item_name,
                            headline=_(u"Items which refer to '%(item_name)s'", item_name=item_name),
                            item_names=refs_here
@@ -1046,18 +1050,25 @@
     u = flaskg.user
     cfg = app.cfg
     msg = None
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
+    if isinstance(item, NonExistent):
+        abort(404)
     if not u.valid:
         msg = _("You must login to use this action: %(action)s.", action="subscribe/unsubscribe"), "error"
     elif not u.may.read(item_name):
         msg = _("You are not allowed to subscribe to an item you may not read."), "error"
-    elif u.is_subscribed_to([item_name]):
+    elif u.is_subscribed_to(item):
         # Try to unsubscribe
-        if not u.unsubscribe(item_name):
-            msg = _("Can't remove regular expression subscription!") + u' ' + \
-                _("Edit the subscription regular expressions in your settings."), "error"
+        if not u.unsubscribe(ITEMID, item.meta[ITEMID]):
+            msg = _(
+                "Can't remove the subscription! You are subscribed to this page in some other way.") + u' ' + _(
+                "Please edit the subscription in your settings."), "error"
     else:
         # Try to subscribe
-        if not u.subscribe(item_name):
+        if not u.subscribe(ITEMID, item.meta[ITEMID]):
             msg = _('You could not get subscribed to this item.'), "error"
     if msg:
         flash(*msg)
@@ -1480,6 +1491,12 @@
     submit_label = L_('Save')
 
 
+class UserSettingsSubscriptionsForm(Form):
+    name = 'usersettings_subscriptions'
+    subscriptions = Subscriptions
+    submit_label = L_('Save')
+
+
 @frontend.route('/+usersettings', methods=['GET', 'POST'])
 def usersettings():
     # TODO use ?next=next_location check if target is in the wiki and not outside domain
@@ -1519,6 +1536,7 @@
         ui=UserSettingsUIForm,
         navigation=UserSettingsNavigationForm,
         options=UserSettingsOptionsForm,
+        subscriptions=UserSettingsSubscriptionsForm,
     )
     forms = dict()
 
@@ -1765,6 +1783,10 @@
     """
     list similar item names
     """
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
     start, end, matches = findMatches(item_name)
     keys = sorted(matches.keys())
     # TODO later we could add titles for the misc ranks:
@@ -1781,6 +1803,7 @@
                 item_names.append(name)
     return render_template("link_list_item_panel.html",
                            headline=_("Items with similar names to '%(item_name)s'", item_name=item_name),
+                           item=item,
                            item_name=item_name,  # XXX no item
                            item_names=item_names)
 
--- a/MoinMoin/config/default.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/config/default.py	Tue Oct 15 21:04:06 2013 -0700
@@ -489,7 +489,7 @@
             DISABLED: False,
             BOOKMARKS: {},
             QUICKLINKS: [],
-            SUBSCRIBED_ITEMS: [],
+            SUBSCRIPTIONS: [],
             EMAIL_SUBSCRIBED_EVENTS: [
                 # XXX PageChangedEvent.__name__
                 # XXX PageRenamedEvent.__name__
--- a/MoinMoin/constants/keys.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/constants/keys.py	Tue Oct 15 21:04:06 2013 -0700
@@ -22,6 +22,7 @@
 # needs more precise name / use case:
 SOMEDICT = u"somedict"
 
+# TODO review plural constants
 CONTENTTYPE = u"contenttype"
 ITEMTYPE = u"itemtype"
 SIZE = u"size"
@@ -66,7 +67,9 @@
 LOCALE = u"locale"
 TIMEZONE = u"timezone"
 ENC_PASSWORD = u"enc_password"
-SUBSCRIBED_ITEMS = u"subscribed_items"
+SUBSCRIPTIONS = u"subscriptions"
+SUBSCRIPTION_IDS = u"subscription_ids"
+SUBSCRIPTION_PATTERNS = u"subscription_patterns"
 BOOKMARKS = u"bookmarks"
 QUICKLINKS = u"quicklinks"
 SESSION_KEY = u"session_key"
@@ -83,6 +86,8 @@
 EMAIL_SUBSCRIBED_EVENTS = u"email_subscribed_events"
 DISABLED = u"disabled"
 EMAIL_UNVALIDATED = u"email_unvalidated"
+NAMERE = u"namere"
+NAMEPREFIX = u"nameprefix"
 
 # in which backend is some revision stored?
 BACKENDNAME = u"backendname"
@@ -91,8 +96,7 @@
     # User objects proxy these attributes of the UserProfile objects:
     NAME, DISABLED, ITEMID, DISPLAY_NAME, ENC_PASSWORD, EMAIL, OPENID,
     MAILTO_AUTHOR, SHOW_COMMENTS, RESULTS_PER_PAGE, EDIT_ON_DOUBLECLICK, SCROLL_PAGE_AFTER_EDIT,
-    EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIBED_ITEMS, QUICKLINKS,
-    CSS_URL,
+    EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIPTIONS, QUICKLINKS, CSS_URL,
 ]
 
 # keys for blog homepages
@@ -114,3 +118,13 @@
 # index names
 LATEST_REVS = 'latest_revs'
 ALL_REVS = 'all_revs'
+
+# values for ACTION key
+ACTION_SAVE = u"SAVE"
+ACTION_REVERT = u"REVERT"
+ACTION_TRASH = u"TRASH"
+ACTION_COPY = u"COPY"
+ACTION_RENAME = u"RENAME"
+
+# defaul LOCALE key value
+DEFAULT_LOCALE = u"en"
--- a/MoinMoin/forms.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/forms.py	Tue Oct 15 21:04:06 2013 -0700
@@ -29,6 +29,9 @@
 from MoinMoin.i18n import _, L_, N_
 from MoinMoin.util.forms import FileStorage
 
+COLS = 60
+ROWS = 10
+
 
 class Enum(BaseEnum):
     """
@@ -152,12 +155,51 @@
     def u(self):
         return self.separator.join(child.u for child in self)
 
+
+class SubscriptionsJoinedString(JoinedString):
+    """ A JoinedString that offers the list of children as value property and also
+    appends the name of the item to the end of ITEMID subscriptions.
+    """
+    @property
+    def value(self):
+        subscriptions = []
+        for child in self:
+            if child.value.startswith(ITEMID):
+                value = re.sub(r"\(.*\)", "", child.value)
+            else:
+                value = child.value
+            subscriptions.append(value)
+        return subscriptions
+
+    @property
+    def u(self):
+        subscriptions = []
+        for child in self:
+            if child.u.startswith(ITEMID):
+                value = re.sub(r"\(.*\)", "", child.u)
+                item = flaskg.storage.document(**{ITEMID: value.split(":")[1]})
+                try:
+                    name_ = item.meta['name'][0]
+                except IndexError:
+                    name_ = "This item doesn't exist"
+                value = "{0} ({1})".format(value, name_)
+            else:
+                value = child.u
+            subscriptions.append(value)
+        return self.separator.join(subscriptions)
+
+
 Tags = MyJoinedString.of(String).with_properties(widget=WIDGET_TEXT).using(
     label=L_('Tags'), optional=True, separator=', ', separator_regex=re.compile(r'\s*,\s*'))
 
 Names = MyJoinedString.of(String).with_properties(widget=WIDGET_TEXT).using(
     label=L_('Names'), optional=True, separator=', ', separator_regex=re.compile(r'\s*,\s*'))
 
+Subscriptions = SubscriptionsJoinedString.of(String).with_properties(
+    widget=WIDGET_MULTILINE_TEXT, rows=ROWS, cols=COLS).using(
+    label=L_('Subscriptions'), optional=True, separator='\n',
+    separator_regex=re.compile(r'[\r\n]+'))
+
 Search = Text.using(default=u'', optional=True).with_properties(widget=WIDGET_SEARCH, placeholder=L_("Search Query"))
 
 _Integer = Integer.validated_by(Converted())
--- a/MoinMoin/i18n/__init__.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/i18n/__init__.py	Tue Oct 15 21:04:06 2013 -0700
@@ -15,8 +15,9 @@
 
 
 from babel import Locale
+from contextlib import contextmanager
 
-from flask import current_app, request
+from flask import current_app, request, _request_ctx_stack
 from flask import g as flaskg
 from flask.ext.babel import Babel, gettext, ngettext, lazy_gettext
 
@@ -66,3 +67,37 @@
     u = getattr(flaskg, 'user', None)
     if u and u.timezone is not None:
         return u.timezone
+
+
+# Original source is a patch to Flask Babel
+# https://github.com/lalinsky/flask-babel/commit/09ee1702c7129598bb202aa40a0e2e19f5414c24
+@contextmanager
+def force_locale(locale):
+    """Temporarily overrides the currently selected locale. Sometimes
+    it is useful to switch the current locale to different one, do
+    some tasks and then revert back to the original one. For example,
+    if the user uses German on the web site, but you want to send
+    them an email in English, you can use this function as a context
+    manager::
+
+        with force_locale('en_US'):
+            send_email(gettext('Hello!'), ...)
+    """
+    ctx = _request_ctx_stack.top
+    if ctx is None:
+        yield
+        return
+    babel = ctx.app.extensions['babel']
+    orig_locale_selector_func = babel.locale_selector_func
+    orig_attrs = {}
+    for key in ('babel_translations', 'babel_locale'):
+        orig_attrs[key] = getattr(ctx, key, None)
+    try:
+        babel.locale_selector_func = lambda: locale
+        for key in orig_attrs:
+            setattr(ctx, key, None)
+        yield
+    finally:
+        babel.locale_selector_func = orig_locale_selector_func
+        for key, value in orig_attrs.iteritems():
+            setattr(ctx, key, value)
--- a/MoinMoin/i18n/_tests/test_i18n.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/i18n/_tests/test_i18n.py	Tue Oct 15 21:04:06 2013 -0700
@@ -5,8 +5,12 @@
 Test for i18n
 """
 
-from MoinMoin.i18n import get_locale, get_timezone
+import pytest
 
+from flask import Flask
+from flask.ext import babel
+
+from MoinMoin.i18n import get_locale, get_timezone, force_locale
 from MoinMoin.i18n import _, L_, N_
 
 
@@ -32,3 +36,19 @@
     assert result1 == 'text1'
     result2 = N_('text1', 'text2', 2)
     assert result2 == 'text2'
+
+
+def test_force_locale():
+    pytest.skip("This test needs to be run with --assert=reinterp or --assert=plain flag")
+    app = Flask(__name__)
+    b = babel.Babel(app)
+
+    @b.localeselector
+    def select_locale():
+        return 'de_DE'
+
+    with app.test_request_context():
+        assert str(babel.get_locale()) == 'de_DE'
+        with force_locale('en_US'):
+            assert str(babel.get_locale()) == 'en_US'
+        assert str(babel.get_locale()) == 'de_DE'
--- a/MoinMoin/items/__init__.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/items/__init__.py	Tue Oct 15 21:04:06 2013 -0700
@@ -48,12 +48,14 @@
 from MoinMoin.constants.keys import (
     NAME, NAME_OLD, NAME_EXACT, WIKINAME, MTIME, ITEMTYPE,
     CONTENTTYPE, SIZE, ACTION, ADDRESS, HOSTNAME, USERID, COMMENT,
-    HASH_ALGORITHM, ITEMID, REVID, DATAID, CURRENT, PARENTID
+    HASH_ALGORITHM, ITEMID, REVID, DATAID, CURRENT, PARENTID, ACTION_SAVE,
+    ACTION_REVERT, ACTION_TRASH, ACTION_RENAME
 )
 from MoinMoin.constants.contenttypes import CHARSET, CONTENTTYPE_NONEXISTENT
 from MoinMoin.constants.itemtypes import (
     ITEMTYPE_NONEXISTENT, ITEMTYPE_USERPROFILE, ITEMTYPE_DEFAULT,
 )
+from MoinMoin.util.notifications import DESTROY_REV, DESTROY_ALL
 
 from .content import content_registry, Content, NonExistentContent, Draw
 
@@ -374,18 +376,21 @@
         """
         if flaskg.storage[name]:
             raise NameNotUniqueError(L_("An item named %s already exists." % name))
-        return self._rename(name, comment, action=u'RENAME')
+        return self._rename(name, comment, action=ACTION_RENAME)
 
     def delete(self, comment=u''):
         """
         delete this item (remove current name from NAME list)
         """
-        return self._rename(None, comment, action=u'TRASH', delete=True)
+        return self._rename(None, comment, action=ACTION_TRASH, delete=True)
 
     def revert(self, comment=u''):
-        return self._save(self.meta, self.content.data, action=u'REVERT', comment=comment)
+        return self._save(self.meta, self.content.data, action=ACTION_REVERT, comment=comment)
 
     def destroy(self, comment=u'', destroy_item=False):
+        action = DESTROY_ALL if destroy_item else DESTROY_REV
+        item_modified.send(app, item_name=self.name, action=action, meta=self.meta,
+                           content=self.rev.data, comment=comment)
         # called from destroy UI/POST
         if destroy_item:
             # destroy complete item with all revisions, metadata, etc.
@@ -455,7 +460,7 @@
         """
         raise NotImplementedError
 
-    def _save(self, meta, data=None, name=None, action=u'SAVE', contenttype_guessed=None, comment=None,
+    def _save(self, meta, data=None, name=None, action=ACTION_SAVE, contenttype_guessed=None, comment=None,
               overwrite=False, delete=False):
         backend = flaskg.storage
         storage_item = backend[self.name]
@@ -517,7 +522,7 @@
                                              contenttype_guessed=contenttype_guessed,
                                              return_rev=True,
                                              )
-        item_modified.send(app._get_current_object(), item_name=name)
+        item_modified.send(app, item_name=name, action=action)
         return newrev.revid, newrev.meta[SIZE]
 
     @property
@@ -669,6 +674,7 @@
         rev_ids = []
         item_templates = self.content.get_templates(self.contenttype)
         return render_template('modify_select_template.html',
+                               item=self,
                                item_name=self.name,
                                itemtype=self.itemtype,
                                rev=self.rev,
@@ -700,6 +706,7 @@
         if method in ['GET', 'HEAD']:
             if isinstance(self.content, NonExistentContent):
                 return render_template('modify_select_contenttype.html',
+                                       item=self,
                                        item_name=self.name,
                                        itemtype=self.itemtype,
                                        group_names=content_registry.group_names,
@@ -737,6 +744,7 @@
                     return redirect(url_for_item(self.name))
         return render_template(self.modify_template,
                                item_name=self.name,
+                               item=self,
                                rows_meta=str(ROWS_META), cols=str(COLS),
                                form=form,
                                search_form=None,
@@ -787,6 +795,7 @@
 
     def _select_itemtype(self):
         return render_template('modify_select_itemtype.html',
+                               item=self,
                                item_name=self.name,
                                itemtypes=item_registry.shown_entries,
                               )
--- a/MoinMoin/items/_tests/test_Content.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/items/_tests/test_Content.py	Tue Oct 15 21:04:06 2013 -0700
@@ -8,6 +8,7 @@
 """
 
 import pytest
+from io import BytesIO
 
 from flask import Markup
 
@@ -232,14 +233,15 @@
         item = Item.create(item_name)
         contenttype = u'text/plain;charset=utf-8'
         meta = {CONTENTTYPE: contenttype}
-        item._save(meta)
+        data1 = "old_data"
+        item._save(meta, data1)
         item1 = Item.create(item_name)
-        data = 'test_data'
+        data2 = 'new_data'
         comment = u'next revision'
-        item1._save(meta, data, comment=comment)
+        item1._save(meta, data2, comment=comment)
         item2 = Item.create(item_name)
         result = Text._render_data_diff_text(item1.content, item1.rev, item2.rev)
-        expected = u'- \n+ test_data'
+        expected = u'- old_data\n+ new_data'
         assert result == expected
         assert item2.content.data == ''
 
@@ -258,5 +260,39 @@
         assert u'<pre class="highlight">test_data\n' in result
         assert item2.content.data == ''
 
+    def test__get_data_diff_text(self):
+        item_name = u'Text_Item'
+        item = Item.create(item_name)
+        contenttypes = dict(texttypes=[u'text/plain;charset=utf-8',
+                                       u'text/x-markdown;charset=utf-8', ],
+                            othertypes=[u'image/png', u'audio/wave',
+                                        u'video/ogg',
+                                        u'application/x-svgdraw',
+                                        u'application/octet-stream', ])
+        for key in contenttypes:
+            for contenttype in contenttypes[key]:
+                meta = {CONTENTTYPE: contenttype}
+                item._save(meta)
+                item_ = Item.create(item_name)
+                oldfile = BytesIO("x")
+                newfile = BytesIO("xx")
+                difflines = item_.content._get_data_diff_text(oldfile, newfile)
+                if key == 'texttypes':
+                    assert difflines == ['- x', '+ xx']
+                else:
+                    assert difflines == []
+
+    def test__get_data_diff_html(self):
+        item_name = u"Test_Item"
+        item = Item.create(item_name)
+        contenttype = u'text/plain;charset=utf-8'
+        meta = {CONTENTTYPE: contenttype}
+        item._save(meta)
+        item_ = Item.create(item_name)
+        oldfile = BytesIO("")
+        newfile = BytesIO("x")
+        difflines = item_.content._get_data_diff_html(oldfile, newfile)
+        assert difflines == [(1, Markup(u''), 1, Markup(u'<span>x</span>'))]
+
 
 coverage_modules = ['MoinMoin.items.content']
--- a/MoinMoin/items/_tests/test_Item.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/items/_tests/test_Item.py	Tue Oct 15 21:04:06 2013 -0700
@@ -12,7 +12,8 @@
 
 from MoinMoin._tests import become_trusted, update_item
 from MoinMoin.items import Item, NonExistent, IndexEntry, MixedIndexEntry
-from MoinMoin.constants.keys import ITEMTYPE, CONTENTTYPE, NAME, NAME_OLD, COMMENT, ACTION, ADDRESS
+from MoinMoin.constants.keys import (ITEMTYPE, CONTENTTYPE, NAME, NAME_OLD, COMMENT,
+                                     ACTION, ACTION_REVERT, ADDRESS)
 from MoinMoin.constants.contenttypes import CONTENTTYPE_NONEXISTENT
 from MoinMoin.constants.itemtypes import ITEMTYPE_NONEXISTENT
 
@@ -329,7 +330,7 @@
         item = Item.create(name)
         item.revert(u'revert')
         item = Item.create(name)
-        assert item.meta[ACTION] == u'REVERT'
+        assert item.meta[ACTION] == ACTION_REVERT
 
     def test_modify(self):
         name = u'Test_Item'
--- a/MoinMoin/items/content.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/items/content.py	Tue Oct 15 21:04:06 2013 -0700
@@ -60,6 +60,8 @@
 from MoinMoin.util.mime import Type, type_moin_document
 from MoinMoin.util.tree import moin_page, html, xlink, docbook
 from MoinMoin.util.iri import Iri
+from MoinMoin.util.diff_text import diff as text_diff
+from MoinMoin.util.diff_html import diff as html_diff
 from MoinMoin.util.crypto import cache_key
 from MoinMoin.util.clock import timed
 from MoinMoin.forms import File
@@ -67,7 +69,8 @@
     GROUP_MARKUP_TEXT, GROUP_OTHER_TEXT, GROUP_IMAGE, GROUP_AUDIO, GROUP_VIDEO,
     GROUP_DRAWING, GROUP_OTHER, CONTENTTYPE_NONEXISTENT, CHARSET
 )
-from MoinMoin.constants.keys import NAME_EXACT, WIKINAME, CONTENTTYPE, SIZE, TAGS, HASH_ALGORITHM
+from MoinMoin.constants.keys import (NAME_EXACT, WIKINAME, CONTENTTYPE, SIZE, TAGS,
+                                     HASH_ALGORITHM, ACTION_SAVE)
 
 
 COLS = 80
@@ -270,6 +273,15 @@
         # override this in child classes
         return ''
 
+    def _get_data_diff_text(self, oldfile, newfile):
+        """ Get the text diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of diff lines in a unified format without trailing linefeeds
+        """
+        return []
+
     def get_templates(self, contenttype=None):
         """ create a list of templates (for some specific contenttype) """
         terms = [Term(WIKINAME, app.cfg.interwikiname), Term(TAGS, u'template')]
@@ -472,7 +484,7 @@
             # everything we expected has been added to the tar file, save the container as revision
             meta = {CONTENTTYPE: self.contenttype}
             data = open(temp_fname, 'rb')
-            self.item._save(meta, data, name=self.name, action=u'SAVE', comment='')
+            self.item._save(meta, data, name=self.name, action=ACTION_SAVE, comment='')
             data.close()
             os.remove(temp_fname)
 
@@ -839,12 +851,15 @@
         """ convert data from storage format to memory format """
         return data.decode(CHARSET).replace(u'\r\n', u'\n')
 
-    def _get_data_diff_html(self, oldrev, newrev, template):
-        from MoinMoin.util.diff_html import diff
-        old_text = self.data_storage_to_internal(oldrev.data.read())
-        new_text = self.data_storage_to_internal(newrev.data.read())
-        storage_item = flaskg.storage[self.name]
-        diffs = [(d[0], Markup(d[1]), d[2], Markup(d[3])) for d in diff(old_text, new_text)]
+    def _render_data_diff_html(self, oldrev, newrev, template):
+        """ Render HTML formatted meta and content diff of 2 revisions
+
+        :param oldrev: old revision object
+        :param newrev: new revision object
+        :param template: name of the template to be rendered
+        :return: HTML data with meta and content diff
+        """
+        diffs = self._get_data_diff_html(oldrev.data, newrev.data)
         return render_template(template,
                                item_name=self.name,
                                oldrev=oldrev,
@@ -852,18 +867,44 @@
                                diffs=diffs,
                                )
 
+    def _get_data_diff_html(self, oldfile, newfile):
+        """ Get the HTML diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of tuples of the format (left lineno, deleted Markup content,
+                 right lineno, added Markup content)
+        """
+        old_text = self.data_storage_to_internal(oldfile.read())
+        new_text = self.data_storage_to_internal(newfile.read())
+        return [(d[0], Markup(d[1]), d[2], Markup(d[3])) for d in html_diff(old_text, new_text)]
+
+    def _get_data_diff_text(self, oldfile, newfile):
+        """ Get the text diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of diff lines in a unified format without trailing linefeeds
+        """
+        old_text = self.data_storage_to_internal(oldfile.read())
+        new_text = self.data_storage_to_internal(newfile.read())
+        return text_diff(old_text.splitlines(), new_text.splitlines())
+
     def _render_data_diff_atom(self, oldrev, newrev):
         """ renders diff in HTML for atom feed """
-        return self._get_data_diff_html(oldrev, newrev, 'diff_text_atom.html')
+        return self._render_data_diff_html(oldrev, newrev, 'diff_text_atom.html')
 
     def _render_data_diff(self, oldrev, newrev):
-        return self._get_data_diff_html(oldrev, newrev, 'diff_text.html')
+        return self._render_data_diff_html(oldrev, newrev, 'diff_text.html')
 
     def _render_data_diff_text(self, oldrev, newrev):
-        from MoinMoin.util import diff_text
-        oldlines = self.data_storage_to_internal(oldrev.data.read()).split('\n')
-        newlines = self.data_storage_to_internal(newrev.data.read()).split('\n')
-        difflines = diff_text.diff(oldlines, newlines)
+        """ Render text diff of 2 revisions' contents
+
+        :param oldrev: old revision object
+        :param newrev: new revision object
+        :return: text data of a content diff
+        """
+        difflines = self._get_data_diff_text(oldrev.data, newrev.data)
         return '\n'.join(difflines)
 
     _render_data_diff_raw = _render_data_diff
--- a/MoinMoin/items/ticket.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/items/ticket.py	Tue Oct 15 21:04:06 2013 -0700
@@ -22,7 +22,8 @@
 from MoinMoin.forms import (Form, OptionalText, OptionalMultilineText, SmallNatural, Tags,
                             Reference, BackReference, SelectSubmit)
 from MoinMoin.storage.middleware.protecting import AccessDenied
-from MoinMoin.constants.keys import ITEMTYPE, CONTENTTYPE, ITEMID, CURRENT, SUPERSEDED_BY, DEPENDS_ON, SUBSCRIBED_ITEMS
+from MoinMoin.constants.keys import (ITEMTYPE, CONTENTTYPE, ITEMID, CURRENT,
+                                     SUPERSEDED_BY, SUBSCRIPTIONS, DEPENDS_ON)
 from MoinMoin.constants.contenttypes import CONTENTTYPE_USER
 from MoinMoin.items import Item, Contentful, register, BaseModifyForm
 from MoinMoin.items.content import NonExistentContent
@@ -59,7 +60,7 @@
         id_ = item.meta[ITEMID]
         self['supersedes'].set(Term(SUPERSEDED_BY, id_))
         self['required_by'].set(Term(DEPENDS_ON, id_))
-        self['subscribers'].set(Term(SUBSCRIBED_ITEMS, id_))
+        self['subscribers'].set(Term(SUBSCRIPTIONS, id_))
 
 
 class TicketForm(BaseModifyForm):
--- a/MoinMoin/log.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/log.py	Tue Oct 15 21:04:06 2013 -0700
@@ -1,7 +1,6 @@
 # Copyright: 2008 MoinMoin:ThomasWaldmann
 # Copyright: 2007 MoinMoin:JohannesBerg
 # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
-
 """
     MoinMoin - init "logging" system
 
@@ -70,14 +69,14 @@
 keys=root
 
 [handlers]
-keys=stderr
+keys=stderr,email
 
 [formatters]
 keys=default
 
 [logger_root]
 level=%(loglevel)s
-handlers=stderr
+handlers=stderr,email
 
 [handler_stderr]
 class=StreamHandler
@@ -85,6 +84,12 @@
 formatter=default
 args=(sys.stderr, )
 
+[handler_email]
+class=MoinMoin.log.EmailHandler
+level=ERROR
+formatter=default
+args=()
+
 [formatter_default]
 format=%(asctime)s %(levelname)s %(name)s:%(lineno)d %(message)s
 datefmt=
@@ -98,6 +103,7 @@
 
 configured = False
 fallback_config = False
+in_email_handler = False
 
 import warnings
 
@@ -170,3 +176,44 @@
         if isinstance(levelnumber, int):  # that list has also the reverse mapping...
             setattr(logger, levelname, levelnumber)
     return logger
+
+
+class EmailHandler(logging.Handler):
+    """ A custom handler class which sends email for each logging event using
+    wiki mail configuration
+    """
+    def __init__(self, toaddrs=[], subject=u''):
+        """ Initialize the handler
+
+        @param toaddrs: address or a list of email addresses whom to send email
+        @param subject: unicode email's subject
+        """
+        logging.Handler.__init__(self)
+        if isinstance(toaddrs, basestring):
+            toaddrs = [toaddrs]
+        self.toaddrs = toaddrs
+        self.subject = subject
+
+    def emit(self, record):
+        """ Emit a record.
+
+        Send the record to the specified addresses
+        """
+        # the app config is accessible after logging is initialized, so set the
+        # arguments and make the decision to send mail or not here
+        from flask import current_app as app
+        if not app.cfg.email_tracebacks:
+            return
+
+        global in_email_handler
+        if in_email_handler:
+            return
+        in_email_handler = True
+        toaddrs = self.toaddrs if self.toaddrs else app.cfg.admin_emails
+        log_level = logging.getLevelName(self.level)
+        subject = self.subject if self.subject else u'[{0}][{1}] Log message'.format(
+            app.cfg.sitename, log_level)
+        msg = self.format(record)
+        from MoinMoin.mail.sendmail import sendmail
+        sendmail(subject, msg, to=toaddrs)
+        in_email_handler = False
--- a/MoinMoin/mail/sendmail.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/mail/sendmail.py	Tue Oct 15 21:04:06 2013 -0700
@@ -59,7 +59,7 @@
         return str(address)
 
 
-def sendmail(subject, text, to=None, cc=None, bcc=None, mail_from=None):
+def sendmail(subject, text, to=None, cc=None, bcc=None, mail_from=None, html=None):
     """ Create and send a text/plain message
 
     Return a tuple of success or error indicator and message.
@@ -76,6 +76,8 @@
     :type bcc: list
     :param mail_from: override default mail_from
     :type mail_from: unicode
+    :param html: html email body text
+    :type html: unicode
 
     :rtype: tuple
     :returns: (is_ok, Description of error or OK message)
@@ -83,6 +85,8 @@
     import smtplib
     import socket
     from email.message import Message
+    from email.mime.multipart import MIMEMultipart
+    from email.mime.text import MIMEText
     from email.charset import Charset, QP
     from email.utils import formatdate, make_msgid
 
@@ -107,18 +111,28 @@
     # Create a message using CHARSET and quoted printable
     # encoding, which should be supported better by mail clients.
     # TODO: check if its really works better for major mail clients
-    msg = Message()
+    text_msg = Message()
     charset = Charset(CHARSET)
     charset.header_encoding = QP
     charset.body_encoding = QP
-    msg.set_charset(charset)
+    text_msg.set_charset(charset)
 
     # work around a bug in python 2.4.3 and above:
-    msg.set_payload('=')
-    if msg.as_string().endswith('='):
+    text_msg.set_payload('=')
+    if text_msg.as_string().endswith('='):
         text = charset.body_encode(text)
 
-    msg.set_payload(text)
+    text_msg.set_payload(text)
+
+    if html:
+        msg = MIMEMultipart('alternative')
+        msg.attach(text_msg)
+        html = html.encode(CHARSET)
+        html_msg = MIMEText(html, 'html')
+        html_msg.set_charset(charset)
+        msg.attach(html_msg)
+    else:
+        msg = text_msg
 
     address = encodeAddress(mail_from, charset)
     msg['From'] = address
--- a/MoinMoin/script/account/disable.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/script/account/disable.py	Tue Oct 15 21:04:06 2013 -0700
@@ -45,7 +45,7 @@
             u.name = u"{0}-{1}".format(u.name, u.id)
             if u.email:
                 u.email = u"{0}-{1}".format(u.email, u.id)
-            u.subscribed_items = []  # avoid using email
+            u.subscriptions = []
             u.save()
             print "- disabled."
         else:
--- a/MoinMoin/script/migration/moin19/import19.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/script/migration/moin19/import19.py	Tue Oct 15 21:04:06 2013 -0700
@@ -257,7 +257,7 @@
                     editlog_data = {  # make something up
                         NAME: [item.name],
                         MTIME: int(os.path.getmtime(path)),
-                        ACTION: u'SAVE',
+                        ACTION: ACTION_SAVE,
                     }
             meta, data = split_body(content)
         meta.update(editlog_data)
@@ -333,7 +333,7 @@
         except KeyError:
             meta = {  # make something up
                 MTIME: int(os.path.getmtime(attpath)),
-                ACTION: u'SAVE',
+                ACTION: ACTION_SAVE,
             }
         meta[NAME] = [u'{0}/{1}'.format(item_name, attach_name)]
         if acl is not None:
@@ -377,12 +377,12 @@
                 if extra:
                     result[NAME_OLD] = extra
                 del result[EXTRA]
-                result[ACTION] = u'RENAME'
+                result[ACTION] = ACTION_RENAME
             elif action == 'SAVE/REVERT':
                 if extra:
                     result[REVERTED_TO] = int(extra)
                 del result[EXTRA]
-                result[ACTION] = u'REVERT'
+                result[ACTION] = ACTION_REVERT
         userid = result[USERID]
         #TODO
         #if userid:
@@ -401,7 +401,7 @@
         meta = dict([(k, v) for k, v in meta.items() if v])  # remove keys with empty values
         if meta.get(ACTION) == u'SAVENEW':
             # replace SAVENEW with just SAVE
-            meta[ACTION] = u'SAVE'
+            meta[ACTION] = ACTION_SAVE
         return meta
 
     def find_attach(self, attachname):
@@ -416,7 +416,7 @@
             raise KeyError
         del meta['__rev']
         del meta[EXTRA]  # we have full name in NAME
-        meta[ACTION] = u'SAVE'
+        meta[ACTION] = ACTION_SAVE
         meta = dict([(k, v) for k, v in meta.items() if v])  # remove keys with empty values
         return meta
 
@@ -479,7 +479,7 @@
         meta[ITEMID] = make_uuid()
         meta[REVID] = make_uuid()
         meta[SIZE] = 0
-        meta[ACTION] = u'SAVE'
+        meta[ACTION] = ACTION_SAVE
         self.meta = meta
         self.data = StringIO('')
 
@@ -532,8 +532,8 @@
         # rename aliasname to display_name:
         metadata[DISPLAY_NAME] = metadata.get('aliasname')
 
-        # rename subscribed_pages to subscribed_items
-        metadata[SUBSCRIBED_ITEMS] = metadata.get('subscribed_pages', [])
+        # transfer subscribed_pages to subscription_patterns
+        metadata[SUBSCRIPTIONS] = migrate_subscriptions(metadata.get('subscribed_pages', []))
 
         # convert bookmarks from usecs (and str) to secs (int)
         metadata[BOOKMARKS] = [(interwiki, int(long(bookmark) / 1000000))
@@ -633,3 +633,19 @@
     else:
         raise ValueError("unsupported content object: {0!r}".format(content))
     return size, HASH_ALGORITHM, unicode(hash.hexdigest())
+
+
+def migrate_subscriptions(subscribed_items):
+    """ Transfer subscribed_items meta to subscriptions meta
+
+    :param subscribed_items: a list of moin19-format subscribed_items
+    :return: subscriptions
+    """
+    subscriptions = []
+    for subscribed_item in subscribed_items:
+        # TODO: try to determine if pagename is not a regexp and create
+        # a subscription_id with a NAME keyword
+        # TODO: support interwiki wikiname
+        wikiname, pagename = subscribed_item.split(":", 1)
+        subscriptions.append("{0}::{2}".format(NAMERE, pagename))
+    return subscriptions
--- a/MoinMoin/signalling/log.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/signalling/log.py	Tue Oct 15 21:04:06 2013 -0700
@@ -7,6 +7,7 @@
 
 
 from .signals import *
+from flask import got_request_exception
 
 from MoinMoin import log
 logging = log.getLogger(__name__)
@@ -19,6 +20,11 @@
 
 
 @item_modified.connect_via(ANY)
-def log_item_modified(app, item_name):
+def log_item_modified(app, item_name, **kwargs):
     wiki_name = app.cfg.interwikiname
     logging.info(u"item {0}:{1} modified".format(wiki_name, item_name))
+
+
+@got_request_exception.connect_via(ANY)
+def log_exception(sender, exception, **extra):
+    logging.exception(exception)
--- a/MoinMoin/storage/middleware/_tests/test_indexing.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/storage/middleware/_tests/test_indexing.py	Tue Oct 15 21:04:06 2013 -0700
@@ -16,7 +16,7 @@
 from flask import g as flaskg
 
 from MoinMoin.constants.keys import (NAME, SIZE, ITEMID, REVID, DATAID, HASH_ALGORITHM, CONTENT, COMMENT,
-                                     LATEST_REVS, ALL_REVS, NAMESPACE)
+                                     LATEST_REVS, ALL_REVS, NAMESPACE, NAMERE, NAMEPREFIX)
 from MoinMoin.constants.namespaces import NAMESPACE_USERPROFILES
 
 from MoinMoin.auth import GivenAuth
@@ -367,6 +367,21 @@
         assert expected_revid == doc[REVID]
         assert unicode(data) == doc[CONTENT]
 
+    def test_indexing_subscriptions(self):
+        item_name = u"foo"
+        meta = dict(name=[item_name, ], subscriptions=[u"{0}::foo".format(NAME),
+                                                       u"{0}::.*".format(NAMERE)])
+        item = self.imw[item_name]
+        item.store_revision(meta, StringIO(str(item_name)))
+        doc1 = self.imw.document(subscription_ids=u"{0}::foo".format(NAME))
+        doc2 = self.imw.document(subscription_patterns=u"{0}::.*".format(NAMERE))
+        assert doc1 is not None
+        assert doc2 is not None
+        doc3 = self.imw.document(subscription_ids=u"{0}::.*".format(NAMERE))
+        doc4 = self.imw.document(subscription_patterns=u"{0}::foo".format(NAMEPREFIX))
+        assert doc3 is None
+        assert doc4 is None
+
     def test_namespaces(self):
         item_name_n = u'normal'
         item = self.imw[item_name_n]
--- a/MoinMoin/storage/middleware/_tests/test_validation.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/storage/middleware/_tests/test_validation.py	Tue Oct 15 21:04:06 2013 -0700
@@ -37,7 +37,7 @@
 
         state = {'trusted': False,  # True for loading a serialized representation or other trusted sources
                  keys.NAME: u'somename',  # name we decoded from URL path
-                 keys.ACTION: u'SAVE',
+                 keys.ACTION: keys.ACTION_SAVE,
                  keys.HOSTNAME: u'localhost',
                  keys.ADDRESS: u'127.0.0.1',
                  keys.USERID: make_uuid(),
@@ -67,11 +67,24 @@
             keys.NAME: [u"user name", ],
             keys.NAMESPACE: u"userprofiles",
             keys.EMAIL: u"foo@example.org",
+            keys.SUBSCRIPTIONS: [u"{0}:{1}".format(keys.ITEMID, make_uuid()),
+                                 u"{0}::foo".format(keys.NAME),
+                                 u"{0}::bar".format(keys.TAGS),
+                                 u"{0}::".format(keys.NAMERE),
+                                 u"{0}:userprofiles:a".format(keys.NAMEPREFIX),
+                                 ]
+        }
+
+        invalid_meta = {
+            keys.SUBSCRIPTIONS: [u"", u"unknown_tag:123",
+                                 u"{0}:123".format(keys.ITEMID),
+                                 u"{0}:foo".format(keys.NAME),
+                                 ]
         }
 
         state = {'trusted': False,  # True for loading a serialized representation or other trusted sources
                  keys.NAME: u'somename',  # name we decoded from URL path
-                 keys.ACTION: u'SAVE',
+                 keys.ACTION: keys.ACTION_SAVE,
                  keys.HOSTNAME: u'localhost',
                  keys.ADDRESS: u'127.0.0.1',
                  keys.WIKINAME: u'ThisWiki',
@@ -86,3 +99,11 @@
                 print e.valid, e
             print m.valid, m
         assert valid
+
+        m = UserMetaSchema(invalid_meta)
+        valid = m.validate(state)
+        assert not valid
+        for e in m.children:
+            if e.name in (keys.SUBSCRIPTIONS,):
+                for value in e:
+                    assert not value.valid
--- a/MoinMoin/storage/middleware/indexing.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/storage/middleware/indexing.py	Tue Oct 15 21:04:06 2013 -0700
@@ -137,6 +137,8 @@
     doc = dict([(key, value)
                 for key, value in meta.items()
                 if key in schema])
+    if SUBSCRIPTION_IDS in schema and SUBSCRIPTIONS in meta:
+        doc[SUBSCRIPTION_IDS], doc[SUBSCRIPTION_PATTERNS] = backend_subscriptions_to_index(meta[SUBSCRIPTIONS])
     for key in [MTIME, PTIME]:
         if key in doc:
             # we have UNIX UTC timestamp (int), whoosh wants datetime
@@ -148,6 +150,25 @@
     return doc
 
 
+def backend_subscriptions_to_index(subscriptions):
+    """ Split subscriptions list to subscription_ids and subscription_patterns lists
+    which match the fields of the whoosh schema
+
+    :param subscriptions: user subscriptions meta
+    :return: tuple containing a list of subscription_ids and a list of
+             subscription_patterns
+    """
+    subscription_ids = []
+    subscription_patterns = []
+    for subscription in subscriptions:
+        keyword = subscription.split(':')[0]
+        if keyword in (ITEMID, NAME, TAGS, ):
+            subscription_ids.append(subscription)
+        elif keyword in (NAMERE, NAMEPREFIX, ):
+            subscription_patterns.append(subscription)
+    return subscription_ids, subscription_patterns
+
+
 from MoinMoin.util.mime import Type, type_moin_document
 from MoinMoin.util.tree import moin_page
 from MoinMoin.converter import default_registry
@@ -322,6 +343,9 @@
             EMAIL: ID(stored=True),
             OPENID: ID(stored=True),
             DISABLED: BOOLEAN(stored=True),
+            LOCALE: ID(stored=True),
+            SUBSCRIPTION_IDS: ID(),
+            SUBSCRIPTION_PATTERNS: ID(),
         }
         latest_revs_fields.update(**userprofile_fields)
 
@@ -1002,7 +1026,7 @@
     def store_revision(self, meta, data, overwrite=False,
                        trusted=False,  # True for loading a serialized representation or other trusted sources
                        name=None,  # TODO name we decoded from URL path
-                       action=u'SAVE',
+                       action=ACTION_SAVE,
                        remote_addr=None,
                        userid=None,
                        wikiname=None,
--- a/MoinMoin/storage/middleware/validation.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/storage/middleware/validation.py	Tue Oct 15 21:04:06 2013 -0700
@@ -25,6 +25,7 @@
 from __future__ import absolute_import, division
 
 import time
+import re
 
 from flatland import Dict, List, Unset, Boolean, Integer, String
 
@@ -215,7 +216,8 @@
     v = element.value
     if not isinstance(v, unicode):
         return False
-    if v not in [u'SAVE', u'REVERT', u'TRASH', u'COPY', u'RENAME', ]:
+    if v not in [keys.ACTION_SAVE, keys.ACTION_REVERT, keys.ACTION_TRASH,
+                 keys.ACTION_COPY, keys.ACTION_RENAME]:
         return False
     return True
 
@@ -318,6 +320,55 @@
     except ValueError:
         return False
 
+
+def subscription_validator(element, state):
+    """
+    a subscription
+    """
+    try:
+        keyword, value = element.value.split(":", 1)
+    except ValueError:
+        element.add_error("Subscription must contain colon delimiters.")
+        return False
+
+    if keyword in (keys.ITEMID, ):
+        value_element = String(value)
+        valid = uuid_validator(value_element, state)
+    elif keyword in (keys.NAME, keys.TAGS, keys.NAMERE, keys.NAMEPREFIX, ):
+        try:
+            namespace, value = value.split(":", 1)
+        except ValueError:
+            element.add_error("Subscription must contain 2 colon delimiters.")
+            return False
+        namespace_element = String(namespace)
+        if not namespace_validator(namespace_element, state):
+            element.add_error("Not a valid namespace value.")
+            return False
+    else:
+        element.add_error(
+            "Subscription must start with one of the keywords: "
+            "'{0}', '{1}', '{2}', '{3}' or '{4}'.".format(keys.ITEMID,
+                                                          keys.NAME, keys.TAGS,
+                                                          keys.NAMERE,
+                                                          keys.NAMEPREFIX))
+        return False
+
+    value_element = String(value)
+    if keyword == keys.TAGS:
+        valid = tag_validator(value_element, state)
+    elif keyword in (keys.NAME, keys.NAMEPREFIX, ):
+        valid = name_validator(value_element, state)
+    elif keyword == keys.NAMERE:
+        try:
+            re.compile(value, re.U)
+            valid = True
+        except re.error:
+            valid = False
+    if not valid:
+        element.add_error("Subscription has invalid value.")
+    return valid
+
+
 common_meta = (
     String.named(keys.ITEMID).validated_by(itemid_validator),
     String.named(keys.REVID).validated_by(revid_validator),
@@ -368,7 +419,7 @@
     Boolean.named(keys.SCROLL_PAGE_AFTER_EDIT).using(optional=True),
     Boolean.named(keys.MAILTO_AUTHOR).using(optional=True),
     List.named(keys.QUICKLINKS).of(String.named('quicklinks')).using(optional=True),
-    List.named(keys.SUBSCRIBED_ITEMS).of(String.named('subscribed_item')).using(optional=True),
+    List.named(keys.SUBSCRIPTIONS).of(String.named('subscription').validated_by(subscription_validator)).using(optional=True),
     List.named(keys.EMAIL_SUBSCRIBED_EVENTS).of(String.named('email_subscribed_event')).using(optional=True),
     #TODO: DuckDict.named('bookmarks').using(optional=True),
     *common_meta
--- a/MoinMoin/templates/itemviews.html	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/templates/itemviews.html	Tue Oct 15 21:04:06 2013 -0700
@@ -57,7 +57,7 @@
             {%- if endpoint == 'frontend.subscribe_item' and user.valid %}
                 <li>
                     <a href="{{ url_for(endpoint, item_name=item_name) }}" title="{{ title }}" rel="nofollow">
-                        {%- if user.is_subscribed_to([item_name]) %}
+                        {%- if user.is_subscribed_to(item) %}
                             {{ _('Unsubscribe') }}
                         {%- else %}
                             {{ _('Subscribe') }}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/content_diff.html	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,30 @@
+{% macro content_diff(diff) %}
+<table border="0" cellpadding="0" cellspacing="0" style="margin:0; padding:1em;
+        width:100%; font-family:monospace; background-color:#F9F9F9;">
+{% for line in diff -%}
+{% if line[:2] == "+ " -%}
+    <tr style="color:#00B000; height:1.2em;">
+{% elif line[:2] == "- " -%}
+    <tr style="color:#991111; height:1.2em;">
+{% elif line[:2] == "@@" -%}
+    <tr style="color:#440088; height:1.2em;">
+{% else -%}
+    <tr style="color:#000000; height:1.2em;">
+{%- endif %}
+    {% if line[:2] == "+ " %}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ line[0] }}</td>
+        <td style="font-family:monospace;"><ins style="text-decoration:none;">{{ line[2:] }}</ins></td>
+    {% elif line[:2] == "- " -%}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ line[0] }}</td>
+        <td style="font-family:monospace;"><del style="text-decoration:none;">{{ line[2:] }}</del></td>
+    {% elif line[:2] == "@@" -%}
+        <td style="font-family:monospace; width:1.2em;"></td>
+        <td style="font-family:monospace;">{{ line }}</td>
+    {% else -%}
+        <td style="font-family:monospace; width:1.2em;"></td>
+        <td style="font-family:monospace;">{{ line[2:] }}</td>
+    {%- endif %}
+    </tr>
+{%- endfor %}
+</table>
+{% endmacro %}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/html_base.html	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,28 @@
+{# HTML mail templates are based on htmlemailboilerplate authored by Sean Powell, The Engage Group, MIT licensed #}
+{# For more information visit: http://htmlemailboilerplate.com/ #}
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>{% block title %}{% endblock %}</title>
+    <style type="text/css">
+        #outlook a {padding:0;}
+        #backgroundTable {margin:0; padding:0; width:100% !important; line-height:100% !important;}
+        .ExternalClass {width:100%;}
+        .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font,
+        .ExternalClass td, .ExternalClass div {line-height:100%;}
+        p {margin:1em 0;}
+        h1, h2, h3, h4, h5, h6 {color:black !important;}
+        table td {border-collapse:collapse; padding:0;}
+    </style>
+</head>
+<body style="width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;">
+<table border="0" cellpadding="0" cellspacing="0" width="100%" id="backgroundTable">
+    <tr>
+        <td>
+            {% block content %}{% endblock %}
+        </td>
+    </tr>
+</table>
+</body>
+</html>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/meta_diff.html	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,29 @@
+{% macro meta_diff(diff) %}
+<table border="0" cellpadding="0" cellspacing="0" style="margin:0; padding:10px;
+        width:100%; font-family:monospace; background-color:#F9F9F9">
+{% for change in diff -%}
+{% if change[0] == "insert" -%}
+    <tr style="color:#00B000; height:1.2em;">
+    {% set marker = '+' -%}
+{% elif change[0] == "delete" -%}
+    <tr style="color:#991111; height:1.2em;">
+    {% set marker = '-' -%}
+{%- endif %}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ marker }}</td>
+        <td style="font-family:monospace;">
+        {% if change[0] == "insert" -%}
+            <ins style="text-decoration:none;">
+                {{ change[1] | join(".") }}{% if change[1] -%}:{%- endif %}
+                {{ change[2] }}
+            </ins>
+        {% elif change[0] == "delete" -%}
+            <del style="text-decoration:none;">
+                {{ change[1] | join(".") }}{% if change[1] -%}:{%- endif %}
+                {{ change[2] }}
+            </del>
+        {% endif %}
+        </td>
+    </tr>
+{%- endfor %}
+</table>
+{% endmacro %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/notification.txt	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,29 @@
+{{ _("Dear Wiki user,") }}
+
+{{ notification_sentence }}
+{{ diff_url }}
+
+{% if comment -%}
+{{ _("Comment:") }}
+{{ comment }}
+{%- endif %}
+
+{% if content_diff_ -%}
+{{ _("Data changes:") }}
+{%- for line in content_diff_ %}
+{{ line }}
+{%- endfor -%}
+{%- endif %}
+
+{% if meta_diff_ -%}
+{{ _("Metadata changes:") }}
+{%- for line in meta_diff_ %}
+{{ line }}
+{%- endfor -%}
+{%- endif %}
+
+{{ _("You are receiving this because you have subscribed to a wiki item on
+'%(wiki_name)s' for change notifications.", wiki_name=wiki_name) }}
+{{ _("Item link: %(item_url)s", item_url=item_url) }}
+{{ _("To unsubscribe use: %(unsubscribe_url)s",
+unsubscribe_url=unsubscribe_url) }}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/notification_main.html	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,46 @@
+{% extends theme("mail/html_base.html") %}
+{% from "mail/content_diff.html" import content_diff %}
+{% from "mail/meta_diff.html" import meta_diff %}
+
+{% block title %}Wiki Notification{% endblock %}
+
+{% block content -%}
+    <div>
+        <p style="text-align:center; margin-bottom:0.6em;">{{ _("Dear Wiki user,") }}</p>
+        <p style="text-align:center; margin-top:0.4em; margin-bottom:0;">
+            {{ notification_sentence }}
+        </p>
+        <p style="text-align:center; margin-top:0.4em; margin-bottom:0;">
+            {{ _("Item link:") }}
+            <a href="{{ item_url }}"
+               style="text-decoration:none; color:#3CA7DD;">{{ item_url }}
+            </a>
+        </p>
+        <p style="text-align:center; margin-top:0.4em;">
+            <a href="{{ diff_url }}"
+                style="text-decoration:none; color:#3CA7DD;">{{ diff_url }}
+            </a>
+        </p>
+    </div>
+    {% if comment -%}
+    <h3 style="margin:0 1.3em 0.5em;">{{ _("Comment") }}</h3>
+    <p>{{ comment }}</p>
+    {%- endif %}
+    {% if content_diff_ -%}
+    <h3 style="margin:1em 1.3em 0.5em;">{{ _("Content") }}</h3>
+    {{ content_diff(content_diff_) }}
+    {%- endif %}
+    {% if meta_diff_ -%}
+    <h3 style="margin:1em 1.3em 0.5em;">{{ _("Metadata") }}</h3>
+    {{ meta_diff(meta_diff_) }}
+    {%- endif %}
+    <div style="font-size:0.7em; margin-top:2em;">
+        <p style="text-align:center; color:#A6A6A6; margin-bottom:0;">
+            {{ _("You are receiving this because you have subscribed to a wiki
+        item on %(wiki_name)s for change notifications.", wiki_name=wiki_name) }}</p>
+        <p style="text-align:center; margin-top:0;"><strong><a
+                href="{{ unsubscribe_url }}"
+            style="text-decoration:none; color:#3CA7DD;">{{ _("Unsubscribe from
+            this item notifications") }}</a></strong></p>
+    </div>
+{%- endblock %}
\ No newline at end of file
--- a/MoinMoin/templates/usersettings_ajax.html	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/templates/usersettings_ajax.html	Tue Oct 15 21:04:06 2013 -0700
@@ -12,4 +12,6 @@
     {{ user_forms.navigation(form) }}
 {% elif part == 'options' %}
     {{ user_forms.options(form) }}
+{% elif part == 'subscriptions' %}
+    {{ user_forms.subscriptions(form) }}
 {% endif %}
--- a/MoinMoin/templates/usersettings_forms.html	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/templates/usersettings_forms.html	Tue Oct 15 21:04:06 2013 -0700
@@ -83,6 +83,17 @@
 {{ gen.form.close() }}
 {% endmacro %}
 
+{% macro subscriptions(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render(form['subscriptions']) }}
+</dl>
+{{ forms.render_hidden('part', 'subscriptions') }}
+{{ forms.render_submit(form) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
 {# javascript functions within common.js are dependent upon the structure, classes and ids defined here #}
 {% macro all_usersettings_forms(form_objs) %}
 <div id="moin-usersettings">
@@ -110,5 +121,9 @@
         <h2 class="moin-settings-head"><a href="#options">{{ _("Options") }}</a></h2>
         {{ options(form_objs.options) }}
     </div>
+    <div id="subscriptions" class="moin-tab-body moin-form">
+        <h2 class="moin-settings-head"><a href="#subscriptions">{{ _("Subscriptions") }}</a></h2>
+        {{ subscriptions(form_objs.subscriptions) }}
+    </div>
 </div>
 {% endmacro %}
--- a/MoinMoin/user.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/MoinMoin/user.py	Tue Oct 15 21:04:06 2013 -0700
@@ -43,12 +43,24 @@
 from MoinMoin.mail import sendmail
 from MoinMoin.util.interwiki import getInterwikiHome, getInterwikiName, is_local_wiki
 from MoinMoin.util.crypto import generate_token, valid_token, make_uuid
+from MoinMoin.util.subscriptions import get_matched_subscription_patterns
 from MoinMoin.storage.error import NoSuchItemError, ItemAlreadyExistsError, NoSuchRevisionError
 
 
-def create_user(username, password, email, openid=None, validate=True, is_encrypted=False, is_disabled=False):
-    """ create a user """
-    # Create user profile
+def create_user(username, password, email, validate=True, is_encrypted=False, **meta):
+    """
+    Create a new user
+
+    :param username: unique user name
+    :param password: user's password - see also is_encrypted param
+    :param email: unique email address
+    :param validate: if True (default) will validate username, password, email
+                        and the uniqueness of the user created
+    :param is_encrypted: if False (default) defines that the password is in
+                        plaintext, when True - password was already encrypted
+    :param meta: a dictionary of key-value pairs that represent user metadata and
+                    will be stored into user profile metadata
+    """
     theuser = User(auth_method="new-user")
 
     # Don't allow creating users with invalid names
@@ -86,14 +98,15 @@
         theuser.profile[EMAIL] = email
 
     # Openid should be unique
+    openid = meta.get(OPENID)
     if validate and openid and search_users(openid=openid):
         return _('This OpenID already belongs to somebody else.')
 
-    theuser.profile[OPENID] = openid
+    theuser.profile[DISABLED] = meta.get("is_disabled", False)
 
-    theuser.profile[DISABLED] = is_disabled
-
-    # save data
+    # TODO requires validation (preferably using flatland)
+    for key, value in meta.items():
+        theuser.profile[key] = value
     theuser.save()
 
 
@@ -184,6 +197,27 @@
     return (name == normalized) and not wikiutil.isGroupItem(name)
 
 
+def assemble_subscription(keyword, value, namespace=None):
+    """ Create a valid subscription string
+
+    :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                    the type of the subscription is determined
+    :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+    :param namespace: the namespace of the subscription
+    :return: subscription string
+    """
+    if keyword == ITEMID:
+        subscription = "{0}:{1}".format(ITEMID, value)
+    elif keyword in [NAME, TAGS, NAMERE, NAMEPREFIX, ]:
+        if namespace is not None:
+            subscription = "{0}:{1}:{2}".format(keyword, namespace, value)
+        else:
+            raise ValueError("The subscription by {0} keyword requires a namespace".format(keyword))
+    else:
+        raise ValueError("Invalid keyword string: {0}".format(keyword))
+    return subscription
+
+
 class UserProfile(object):
     """ A User Profile"""
 
@@ -533,82 +567,83 @@
 
     # Subscribed Items -------------------------------------------------------
 
-    def is_subscribed_to(self, pagelist):
-        """ Check if user subscription matches any page in pagelist.
-
-        The subscription contains interwiki page names. e.g 'WikiName:Page_Name'
+    def is_subscribed_to(self, item):
+        """ Check if user is subscribed to the following item
 
-        TODO: check if it's fast enough when getting called for many
-              users from page.getSubscribersList()
-
-        :param pagelist: list of pages to check for subscription
+        :param item: Item object
         :rtype: bool
-        :returns: if user is subscribed any page in pagelist
+        :returns: if user is subscribed to the item
         """
         if not self.valid:
             return False
 
-        # Create a new list with interwiki names.
-        pages = [getInterwikiName(pagename) for pagename in pagelist]
-        # Create text for regular expression search
-        text = '\n'.join(pages)
-
-        for pattern in self.subscribed_items:
-            # Try simple match first
-            if pattern in pages:
-                return True
-            # Try regular expression search, skipping bad patterns
-            try:
-                pattern = re.compile(r'^{0}$'.format(pattern), re.M)
-            except re.error:
-                continue
-            if pattern.search(text):
-                return True
+        meta = item.meta
+        try:
+            item_namespace = meta[NAMESPACE]
+        except KeyError:
+            return False
+        subscriptions = {"{0}:{1}".format(ITEMID, meta[ITEMID])}
+        subscriptions.update("{0}:{1}:{2}".format(NAME, item_namespace, name)
+                             for name in meta[NAME])
+        subscriptions.update("{0}:{1}:{2}".format(TAGS, item_namespace, tag)
+                             for tag in meta[TAGS])
+        if subscriptions & set(self.subscriptions):
+            return True
 
-        return False
-
-    def subscribe(self, pagename):
-        """ Subscribe to a wiki page.
-
-        Page names are saved as interwiki names.
-
-        :param pagename: name of the page to subscribe
-        :type pagename: unicode
-        :rtype: bool
-        :returns: if page was subscribed
-        """
-        pagename = getInterwikiName(pagename)
-        subscribed_items = self.subscribed_items
-        if pagename not in subscribed_items:
-            subscribed_items.append(pagename)
-            self.save(force=True)
-            # XXX SubscribedToPageEvent
+        if get_matched_subscription_patterns(self.subscriptions, **meta):
             return True
         return False
 
-    def unsubscribe(self, pagename):
-        """ Unsubscribe a wiki page.
-
-        Try to unsubscribe by removing interwiki name from the subscription
-        list.
-
-        Its possible that the user will be subscribed to a page by more
-        than one pattern. It can be both interwiki name and a regex pattern that
-        both match the page. Therefore, we must check if the user is
-        still subscribed to the page after we try to remove names from the list.
+    def subscribe(self, keyword, value, namespace=None):
+        """ Subscribe to a wiki page.
 
-        :param pagename: name of the page to subscribe
-        :type pagename: unicode
+        The user can subscribe in 5 different ways:
+        * by itemid - ITEMID:<itemid value>
+        * by item name - NAME:<namespace>:<name value>
+        * by a tagname - TAGS:<namespace>:<tag value>
+        * by a prefix name - NAMEPREFIX:<namespace>:<name prefix>
+        * by a regular expression - NAMERE:<namespace>:<name regexp>
+
+:       :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                        the type of the subscription is determined
+        :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+        :param namespace: the namespace of the subscription; itemid keyword doesn't
+                            require a namespace
         :rtype: bool
-        :returns: if unsubscribe was successful. If the user has a
-            regular expression that matches, unsubscribe will always fail.
+        :returns: if user was subscribed
         """
-        interWikiName = getInterwikiName(pagename)
-        subscribed_items = self.profile[SUBSCRIBED_ITEMS]
-        if interWikiName and interWikiName in subscribed_items:
-            subscribed_items.remove(interWikiName)
+        subscription = assemble_subscription(keyword, value, namespace)
+        subscriptions = self.subscriptions
+        if subscription not in subscriptions:
+            subscriptions.append(subscription)
             self.save(force=True)
-        return not self.is_subscribed_to([pagename])
+            return True
+        return False
+
+    def unsubscribe(self, keyword, value, namespace=None, item=None):
+        """ Unsubscribe from a wiki page.
+
+        Same as for subscribing, user can also unsubscribe in 5 ways.
+        The unsubscribe action doesn't guarantee that user will not receive any
+        notification for this item, since user can be subscribed by some other
+        patterns that match current item.
+
+        :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                        the type of the subscription is determined
+        :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+        :param namespace: the namespace of the subscription; itemid keyword doesn't
+                            require a namespace
+        :param item: Item object to check if the user is still subscribed
+        :rtype: bool
+        :returns: if user was unsubscribed
+        """
+        subscription = assemble_subscription(keyword, value, namespace)
+        subscriptions = self.subscriptions
+        if subscription in subscriptions:
+            subscriptions.remove(subscription)
+            self.save(force=True)
+            return not self.is_subscribed_to(item) if item else True
+        return False
 
     # Quicklinks -------------------------------------------------------------
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_diff_datastruct.py	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,148 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.diff_datastruct Tests
+"""
+
+import pytest
+
+from MoinMoin.util.diff_datastruct import diff, make_text_diff, Undefined, INSERT, DELETE
+
+
+class TestDiffDatastruct(object):
+
+    def _test_make_text_diff(self, tests):
+        for changes, expected in tests:
+            for got in make_text_diff(changes):
+                assert got == expected
+
+    def test_diff_no_change(self):
+        datastruct = [None, True, 42, u"value", [1, 2, 3], dict(one=1, two=2)]
+        for d in datastruct:
+            assert diff(d, d) == []
+
+    def test_diff_none(self):
+        tests = [(None, None, []),
+                 (Undefined, None, [(INSERT, [], None)]),
+                 (None, Undefined, [(DELETE, [], None)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_bool(self):
+        tests = [(True, True, []),
+                 (Undefined, True, [(INSERT, [], True)]),
+                 (True, Undefined, [(DELETE, [], True)]),
+                 (True, False, [(DELETE, [], True), (INSERT, [], False)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_int(self):
+        tests = [(1, 1, []),
+                 (Undefined, 2, [(INSERT, [], 2)]),
+                 (2, Undefined, [(DELETE, [], 2)]),
+                 (3, 4, [(DELETE, [], 3), (INSERT, [], 4)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_float(self):
+        tests = [(1.1, 1.1, []),
+                 (Undefined, 2.2, [(INSERT, [], 2.2)]),
+                 (2.2, Undefined, [(DELETE, [], 2.2)]),
+                 (3.3, 4.4, [(DELETE, [], 3.3), (INSERT, [], 4.4)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_unicode(self):
+        tests = [(u"same", u"same", []),
+                 (Undefined, u"new", [(INSERT, [], u"new")]),
+                 (u"old", Undefined, [(DELETE, [], u"old")]),
+                 (u"some value", u"some other value",
+                  [(DELETE, [], u"some value"), (INSERT, [], u"some other value")])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_list(self):
+        tests = [([1], [1], []),
+                 (Undefined, [2], [(INSERT, [], [2])]),
+                 ([2], Undefined, [(DELETE, [], [2])]),
+                 ([1, 2], [2, 3], [(DELETE, [], [1]), (INSERT, [], [3])]),
+                 ([9, 8], [8, 7, 6, 5], [(DELETE, [], [9]), (INSERT, [], [7, 6, 5])])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_dict(self):
+        tests = [(dict(same=1), dict(same=1), []),
+                 (Undefined, dict(new=1), [(INSERT, ["new"], 1)]),
+                 (dict(old=1), Undefined, [(DELETE, ["old"], 1)]),
+                 (dict(same=1, old=2), dict(same=1, new1=3, new2=4),
+                  [(INSERT, ["new1"], 3), (INSERT, ["new2"], 4),
+                   (DELETE, ["old"], 2)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_nested_dict(self):
+        tests = [(dict(key=dict(same=None)), dict(key=dict(same=None)), []),
+                 (dict(key=dict()), dict(key=dict(added=None)), [(INSERT, ["key", "added"], None)]),
+                 (dict(key=dict(removed=None)), dict(key=dict()), [(DELETE, ["key", "removed"], None)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_str_unicode_keys(self):
+        d1 = {"old": u"old", u"same1": u"same1", "same2": u"same2"}
+        d2 = {u"new": u"new", "same1": u"same1", u"same2": u"same2"}
+        assert diff(d1, d2) == [(INSERT, ["new"], u"new"),
+                                (DELETE, ["old"], u"old")]
+
+    def test_diff_errors(self):
+        tests = [(u"foo", True),
+                 ((1, 2, ), (3, 4, )),
+                 (dict(key=(1, 2, )), dict()),
+                 (None, [1, 2, ])]
+        for d1, d2 in tests:
+            with pytest.raises(TypeError):
+                diff(d1, d2)
+
+    def test_make_text_diff_empty(self):
+        for got in make_text_diff([]):
+            assert got == u""
+
+    def test_make_text_diff_none(self):
+        tests = [([(INSERT, [], None)], u"+ None"),
+                 ([(DELETE, [], None)], u"- None")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_bool(self):
+        tests = [([(INSERT, [], True)], u"+ True"),
+                 ([(DELETE, [], False)], u"- False")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_int(self):
+        tests = [([(INSERT, [], 123)], u"+ 123"),
+                 ([(DELETE, [], 321)], u"- 321")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_float(self):
+        tests = [([(INSERT, [], 1.2)], u"+ 1.2"),
+                 ([(DELETE, [], 3.4)], u"- 3.4")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_unicode(self):
+        tests = [([(INSERT, [], u"new value")], u"+ new value"),
+                 ([(DELETE, [], u"old value")], u"- old value")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_list(self):
+        tests = [([(INSERT, [], [1, 2, 3, ])], u"+ [1, 2, 3]"),
+                 ([(DELETE, [], [4, 5, ])], u"- [4, 5]")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_one_key(self):
+        tests = [([(INSERT, ["key"], u"new value")], u"+ key: new value"),
+                 ([(DELETE, ["key"], u"old value")], u"- key: old value")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_multiple_keys(self):
+        tests = [([(INSERT, ["key1", "key2"], 1)], u"+ key1.key2: 1"),
+                 ([(DELETE, ["key1", "key2", "key3"], 2)], u"- key1.key2.key3: 2")]
+        self._test_make_text_diff(tests)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_notifications.py	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,102 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.notifications Tests
+"""
+
+from io import StringIO
+
+from flask import g as flaskg
+from flask import current_app as app
+from flask import url_for
+
+from MoinMoin.constants.keys import ACTION_SAVE, ACTION_TRASH
+from MoinMoin.items import Item
+from MoinMoin.util.diff_datastruct import diff as dict_diff
+from MoinMoin.util.notifications import Notification, get_item_last_revisions, DESTROY_REV, DESTROY_ALL
+
+
+class TestNotifications(object):
+    reinit_storage = True
+
+    def setup_method(self, method):
+        self.imw = flaskg.unprotected_storage
+        self.item_name = u"foo"
+
+    def test_get_last_item_revisions(self):
+        assert get_item_last_revisions(app, self.item_name) == []
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ]),
+                                   StringIO(u'x'), trusted=True, return_rev=True)
+        assert get_item_last_revisions(app, self.item_name) == [rev1]
+        rev2 = item.store_revision(dict(name=[self.item_name, ]),
+                                   StringIO(u'xx'), trusted=True, return_rev=True)
+        assert get_item_last_revisions(app, self.item_name) == [rev2, rev1]
+        rev3 = item.store_revision(dict(name=[self.item_name, ]),
+                                   StringIO(u'xxx'), trusted=True, return_rev=True)
+        assert get_item_last_revisions(app, self.item_name) == [rev3, rev2]
+
+    def test_get_content_diff(self):
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ], contenttype='text/plain'),
+                                   StringIO(u'x'), trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev1], action=ACTION_SAVE)
+        assert notification.get_content_diff() == ["+ x"]
+        rev1.data.seek(0, 0)
+
+        rev2 = item.store_revision(dict(name=[self.item_name, ], contenttype='text/plain'),
+                                   StringIO(u'xx'), trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev2, rev1], action=ACTION_SAVE)
+        assert notification.get_content_diff() == ['- x', '+ xx']
+        rev2.data.seek(0, 0)
+
+        notification = Notification(app, self.item_name, [rev2, rev1], action=ACTION_TRASH)
+        assert notification.get_content_diff() == ['- xx']
+        rev2.data.seek(0, 0)
+
+        item = Item.create(self.item_name)
+        notification = Notification(app, self.item_name, [], content=item.rev.data,
+                                    meta=rev2.meta, action=DESTROY_REV)
+        assert notification.get_content_diff() == ['- xx']
+        rev2.data.seek(0, 0)
+
+        item = Item.create(self.item_name)
+        notification = Notification(app, self.item_name, [], content=item.rev.data,
+                                    meta=rev2.meta, action=DESTROY_ALL)
+        assert notification.get_content_diff() == ['- xx']
+
+    def test_get_meta_diff(self):
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'x'),
+                                   trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev1], action=ACTION_SAVE)
+        assert notification.get_meta_diff() == dict_diff(dict(), rev1.meta._meta)
+
+        rev2 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'xx'),
+                                   trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev2, rev1], action=ACTION_SAVE)
+        assert notification.get_meta_diff() == dict_diff(rev1.meta._meta, rev2.meta._meta)
+
+        actions = [DESTROY_REV, DESTROY_ALL, ACTION_TRASH, ]
+        for action in actions:
+            notification = Notification(app, self.item_name, [rev2, rev1], meta=rev2.meta, action=action)
+            assert notification.get_meta_diff() == dict_diff(rev2.meta._meta, dict())
+
+    def test_generate_diff_url(self):
+        domain = "http://test.com"
+        notification = Notification(app, self.item_name, [], action=DESTROY_REV)
+        assert notification.generate_diff_url(domain) == u""
+
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'x'),
+                                   trusted=True, return_rev=True)
+        notification.revs = [rev1]
+        assert notification.generate_diff_url(domain) == u""
+
+        rev2 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'xx'),
+                                   trusted=True, return_rev=True)
+        notification.revs = [rev2, rev1]
+        assert notification.generate_diff_url(domain) == u"{0}{1}".format(
+            domain, url_for('frontend.diff', item_name=self.item_name,
+                            rev1=rev1.revid, rev2=rev2.revid))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_subscriptions.py	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,133 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.subscriptions Tests
+"""
+
+import pytest
+
+from MoinMoin import user
+from MoinMoin.items import Item
+from MoinMoin.constants.keys import (ACL, ITEMID, CONTENTTYPE, NAME, NAMERE, NAMEPREFIX,
+                                     SUBSCRIPTIONS, TAGS)
+from MoinMoin.constants.namespaces import NAMESPACE_DEFAULT, NAMESPACE_USERPROFILES
+from MoinMoin.util.subscriptions import get_subscribers, get_matched_subscription_patterns
+
+
+class TestSubscriptions(object):
+    reinit_storage = True
+
+    def setup_method(self, method):
+        # create an item
+        self.item_name = u'foo'
+        self.tagname = u'XXX'
+        self.namespace = NAMESPACE_DEFAULT
+        meta = {CONTENTTYPE: u'text/plain;charset=utf-8', TAGS: [self.tagname]}
+        item = Item.create(self.item_name)
+        item._save(meta)
+        self.item = Item.create(self.item_name)
+
+    def test_get_subscribers(self):
+        users = get_subscribers(**self.item.meta)
+        assert users == set()
+
+        name1 = u'baz'
+        password = u'password'
+        email1 = u'baz@example.org'
+        name2 = u"bar"
+        email2 = u"bar@example.org"
+        name3 = u"barbaz"
+        email3 = u"barbaz@example.org"
+        user.create_user(username=name1, password=password, email=email1, validate=False, locale=u'en')
+        user1 = user.User(name=name1, password=password)
+        user.create_user(username=name2, password=password, email=email2, validate=False)
+        user2 = user.User(name=name2, password=password)
+        user.create_user(username=name3, password=password, email=email3, locale=u"en")
+        user3 = user.User(name=name3, password=password, email1=email3)
+        subscribers = get_subscribers(**self.item.meta)
+        assert subscribers == set()
+
+        namere = r'.*'
+        nameprefix = u"fo"
+        subscription_lists = [
+            ["{0}:{1}".format(ITEMID, self.item.meta[ITEMID])],
+            ["{0}:{1}:{2}".format(TAGS, self.namespace, self.tagname)],
+            ["{0}:{1}:{2}".format(NAME, self.namespace, self.item_name)],
+            ["{0}:{1}:{2}".format(NAMERE, self.namespace, namere)],
+            ["{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, nameprefix)],
+        ]
+        users = [user1, user2, user3]
+        expected_names = {user1.name0, user2.name0}
+        for subscriptions in subscription_lists:
+            for user_ in users:
+                user_.profile._meta[SUBSCRIPTIONS] = subscriptions
+                user_.save(force=True)
+            subscribers = get_subscribers(**self.item.meta)
+            subscribers_names = {subscriber.name for subscriber in subscribers}
+            assert subscribers_names == expected_names
+
+        meta = {CONTENTTYPE: u'text/plain;charset=utf-8',
+                ACL: u"{0}: All:read,write".format(user1.name0)}
+        self.item._save(meta, comment=u"")
+        self.item = Item.create(self.item_name)
+        subscribers = get_subscribers(**self.item.meta)
+        assert {subscriber.name for subscriber in subscribers} == {user2.name0}
+
+    def test_get_matched_subscription_patterns(self):
+        meta = self.item.meta
+        patterns = get_matched_subscription_patterns([], **meta)
+        assert patterns == []
+        non_matching_patterns = [
+            "{0}:{1}:{2}".format(NAMERE, NAMESPACE_USERPROFILES, ".*"),
+            "{0}:{1}:{2}".format(NAMERE, self.namespace, "\d+"),
+            "{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, "bar"),
+        ]
+        patterns = get_matched_subscription_patterns(non_matching_patterns, **meta)
+        assert patterns == []
+
+        matching_patterns = [
+            "{0}:{1}:{2}".format(NAMERE, self.namespace, "fo+"),
+            "{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, "fo"),
+        ]
+        patterns = get_matched_subscription_patterns(non_matching_patterns + matching_patterns, **meta)
+        assert patterns == matching_patterns
+
+    def test_perf_get_subscribers(self):
+        pytest.skip("usually we do no performance tests")
+        password = u"password"
+        subscriptions = [
+            "{0}:{1}".format(ITEMID, self.item.meta[ITEMID]),
+            "{0}:{1}:{2}".format(NAME, self.namespace, self.item_name),
+            "{0}:{1}:{2}".format(TAGS, self.namespace, self.tagname),
+            "{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, u"fo"),
+            "{0}:{1}:{2}".format(NAMERE, self.namespace, r"\wo")
+        ]
+        users = set()
+        expected_names = set()
+        for i in xrange(10000):
+            i = unicode(i)
+            user.create_user(username=i, password=password, email="{0}@example.org".format(i),
+                             validate=False, locale=u'en')
+            user_ = user.User(name=i, password=password)
+            users.add(user_)
+            expected_names.add(user_.name0)
+
+        users_sliced = list(users)[:100]
+        expected_names_sliced = {user_.name0 for user_ in users_sliced}
+        tests = [(users_sliced, expected_names_sliced), (users, expected_names)]
+
+        import time
+        for users_, expected_names_ in tests:
+            print "\nTesting {0} subscribers from a total of {1} users".format(
+                len(users_), len(users))
+            for subscription in subscriptions:
+                for user_ in users_:
+                    user_.profile._meta[SUBSCRIPTIONS] = [subscription]
+                    user_.save(force=True)
+                t = time.time()
+                subscribers = get_subscribers(**self.item.meta)
+                elapsed_time = time.time() - t
+                print "{0}: {1} s".format(subscription.split(':', 1)[0], elapsed_time)
+                subscribers_names = {subscriber.name for subscriber in subscribers}
+                assert subscribers_names == expected_names_
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/diff_datastruct.py	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,94 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+import difflib
+from types import NoneType
+from collections import Hashable
+
+INSERT = u"insert"
+DELETE = u"delete"
+REPLACE = u"replace"
+
+
+class UndefinedType(object):
+    """ Represents a non-existing value """
+
+
+Undefined = UndefinedType()
+
+
+def diff(d1, d2, basekeys=None):
+    """ Get the diff of 2 datastructures (usually 2 meta dicts)
+
+    :param d1: old datastructure
+    :param d2: new datastructure
+    :param basekeys: list of data keys' basenames (default: None, meaning [])
+    :return: a list of tuples of the format (<change type>, <basekeys>, <value>)
+             that can be used to format a diff
+    """
+    if basekeys is None:
+        basekeys = []
+    changes = []
+
+    if isinstance(d1, UndefinedType) and isinstance(d2, (dict, list, )):
+        d1 = type(d2)()
+    elif isinstance(d2, UndefinedType) and isinstance(d1, (dict, list, )):
+        d2 = type(d1)()
+    if isinstance(d1, dict) and isinstance(d2, dict):
+        added = set(d2) - set(d1)
+        removed = set(d1) - set(d2)
+        all_ = set(d1) | set(d2)
+        for key in sorted(all_):
+            keys = basekeys + [key]
+            if key in added:
+                changes.extend(diff(Undefined, d2[key], keys))
+            elif key in removed:
+                changes.extend(diff(d1[key], Undefined, keys))
+            else:
+                changes.extend(diff(d1[key], d2[key], keys))
+    elif isinstance(d1, list) and isinstance(d2, list):
+        hashable = all(isinstance(d1, unicode) or all(
+            isinstance(v, Hashable) for v in d) for d in [d1, d2])
+        if hashable:
+            matches = difflib.SequenceMatcher(None, d1, d2)
+            for tag, d1_start, d1_end, d2_start, d2_end in matches.get_opcodes():
+                if tag == REPLACE:
+                    changes.extend([(DELETE, basekeys, d1[d1_start:d1_end]),
+                                    (INSERT, basekeys, d2[d2_start:d2_end])])
+                elif tag == DELETE:
+                    changes.append((DELETE, basekeys, d1[d1_start:d1_end]))
+                elif tag == INSERT:
+                    changes.append((INSERT, basekeys, d2[d2_start:d2_end]))
+        else:
+            changes.extend(diff(unicode(d1), unicode(d2), basekeys))
+    elif any(isinstance(d, (NoneType, bool, int, long, float, unicode, )) for d in (d1, d2)):
+        if isinstance(d1, UndefinedType):
+            changes.append((INSERT, basekeys, d2))
+        elif isinstance(d2, UndefinedType):
+            changes.append((DELETE, basekeys, d1))
+        elif type(d1) == type(d2):
+            if d1 != d2:
+                changes.extend([(DELETE, basekeys, d1), (INSERT, basekeys, d2)])
+        else:
+            raise TypeError(
+                "Unsupported diff between {0} and {1} data types".format(
+                    type(d1), type(d2)))
+    else:
+        raise TypeError(
+            "Unsupported diff between {0} and {1} data types".format(
+                type(d1), type(d2)))
+    return changes
+
+
+def make_text_diff(changes):
+    """ Transform change tuples into text diffs
+
+    :param changes: a list of tuples of the format (<change type>, <basekeys>, <value>)
+                    that represent a diff
+    :return: a generator of text diffs
+    """
+    marker = {INSERT: u"+", DELETE: u"-"}
+    for change_type, keys, value in changes:
+        yield "{0} {1}{2}{3}".format(marker[change_type],
+                                     ".".join(unicode(key) for key in keys),
+                                     ": " if keys else "", unicode(value))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/notifications.py	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,222 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - Notifications
+"""
+
+from io import BytesIO
+
+from blinker import ANY
+from urlparse import urljoin
+from whoosh.query import Term, And
+
+from flask import url_for, g as flaskg
+
+from MoinMoin.constants.keys import (ACTION_COPY, ACTION_RENAME, ACTION_REVERT,
+                                     ACTION_SAVE, ACTION_TRASH, ALL_REVS, CONTENTTYPE,
+                                     MTIME, NAME_EXACT, WIKINAME)
+from MoinMoin.i18n import _, L_, N_
+from MoinMoin.i18n import force_locale
+from MoinMoin.items.content import Content
+from MoinMoin.mail.sendmail import sendmail
+from MoinMoin.themes import render_template
+from MoinMoin.signalling.signals import item_modified
+from MoinMoin.util.subscriptions import get_subscribers
+from MoinMoin.util.diff_datastruct import make_text_diff, diff as dict_diff
+
+from MoinMoin import log
+logging = log.getLogger(__name__)
+
+# additional action values
+ACTION_CREATE = u"CREATE"
+ACTION_MODIFY = u"MODIFY"
+
+# destroy types
+DESTROY_REV = u"DESTROY_REV"
+DESTROY_ALL = u"DESTROY_ALL"
+
+
+def msgs():
+    """ Encapsulates the main notification messages
+
+    :return: a dictionary of notification messages
+    """
+    _ = lambda x: x
+    messages = {
+        ACTION_CREATE: _("The '%(item_name)s' item on '%(wiki_name)s' has been created by %(user_name)s:"),
+        ACTION_MODIFY: _("The '%(item_name)s' item on '%(wiki_name)s' has been modified by %(user_name)s:"),
+        ACTION_RENAME: _("The '%(item_name)s' item on '%(wiki_name)s' has been renamed by %(user_name)s:"),
+        ACTION_COPY: _("The '%(item_name)s' item on '%(wiki_name)s' has been copied by %(user_name)s:"),
+        ACTION_REVERT: _("The '%(item_name)s' item on '%(wiki_name)s' has been reverted by %(user_name)s:"),
+        ACTION_TRASH: _("The '%(item_name)s' item on '%(wiki_name)s' has been deleted by %(user_name)s:"),
+        DESTROY_REV: _("The '%(item_name)s' item on '%(wiki_name)s' has one revision destroyed by %(user_name)s:"),
+        DESTROY_ALL: _("The '%(item_name)s' item on '%(wiki_name)s' has been destroyed by %(user_name)s:"),
+    }
+    return messages
+
+MESSAGES = msgs()
+
+
+class Notification(object):
+    """
+    Represents a mail notification about an item change
+    """
+    txt_template = "mail/notification.txt"
+    html_template = "mail/notification_main.html"
+
+    def __init__(self, app, item_name, revs, **kwargs):
+        self.app = app
+        self.item_name = item_name
+        self.revs = revs
+        self.action = kwargs.get('action', None)
+        self.content = kwargs.get('content', None)
+        self.meta = kwargs.get('meta', None)
+        self.comment = kwargs.get('comment', None)
+        self.wiki_name = self.app.cfg.interwikiname
+
+        if self.action == ACTION_SAVE:
+            self.action = ACTION_CREATE if len(self.revs) == 1 else ACTION_MODIFY
+
+        if self.action == ACTION_TRASH:
+            self.meta = self.revs[0].meta
+
+        kw = dict(item_name=self.item_name, wiki_name=self.wiki_name, user_name=flaskg.user.name0)
+        self.notification_sentence = L_(MESSAGES[self.action], **kw)
+
+    def get_content_diff(self):
+        """ Create a content diff for the last item change
+
+        :return: list of diff lines
+        """
+        if self.action in [DESTROY_REV, DESTROY_ALL, ]:
+            contenttype = self.meta[CONTENTTYPE]
+            oldfile, newfile = self.content, BytesIO("")
+        elif self.action == ACTION_TRASH:
+            contenttype = self.meta[CONTENTTYPE]
+            oldfile, newfile = self.revs[0].data, BytesIO("")
+        else:
+            newfile = self.revs[0].data
+            if len(self.revs) == 1:
+                contenttype = self.revs[0].meta[CONTENTTYPE]
+                oldfile = BytesIO("")
+            else:
+                from MoinMoin.apps.frontend.views import _common_type
+                contenttype = _common_type(self.revs[0].meta[CONTENTTYPE], self.revs[1].meta[CONTENTTYPE])
+                oldfile = self.revs[1].data
+        content = Content.create(contenttype)
+        return content._get_data_diff_text(oldfile, newfile)
+
+    def get_meta_diff(self):
+        """ Create a meta diff for the last item change
+
+        :return: a list of tuples of the format (<change type>, <basekeys>, <value>)
+                 that can be used to format a diff
+        """
+        if self.action in [ACTION_TRASH, DESTROY_REV, DESTROY_ALL, ]:
+            old_meta, new_meta = dict(self.meta), dict()
+        else:
+            new_meta = dict(self.revs[0].meta)
+            if len(self.revs) == 1:
+                old_meta = dict()
+            else:
+                old_meta = dict(self.revs[1].meta)
+        meta_diff = dict_diff(old_meta, new_meta)
+        return meta_diff
+
+    def generate_diff_url(self, domain):
+        """ Generate the URL that leads to diff page of the last 2 revisions
+
+        :param domain: domain name
+        :return: the absolute URL to the diff page
+        """
+        if len(self.revs) < 2:
+            return u""
+        else:
+            revid1 = self.revs[1].revid
+            revid2 = self.revs[0].revid
+        diff_rel_url = url_for('frontend.diff', item_name=self.item_name, rev1=revid1, rev2=revid2)
+        return urljoin(domain, diff_rel_url)
+
+    def render_templates(self, content_diff, meta_diff):
+        """ Render both plain text and HTML templates by providing all the
+        necessary arguments
+
+        :return: tuple consisting of plain text and HTML notification message
+         """
+        meta_diff_txt = list(make_text_diff(meta_diff))
+        domain = self.app.cfg.interwiki_map[self.app.cfg.interwikiname]
+        unsubscribe_url = urljoin(domain, url_for('frontend.subscribe_item',
+                                                  item_name=self.item_name))
+        diff_url = self.generate_diff_url(domain)
+        item_url = urljoin(domain, url_for('frontend.show_item', item_name=self.item_name))
+        if self.comment is not None:
+            comment = self.meta["comment"]
+        else:
+            comment = self.revs[0].meta["comment"]
+        txt_template = render_template(Notification.txt_template,
+                                       wiki_name=self.wiki_name,
+                                       notification_sentence=self.notification_sentence,
+                                       diff_url=diff_url,
+                                       item_url=item_url,
+                                       comment=comment,
+                                       content_diff_=content_diff,
+                                       meta_diff_=meta_diff_txt,
+                                       unsubscribe_url=unsubscribe_url,
+                                       )
+        html_template = render_template(Notification.html_template,
+                                        wiki_name=self.wiki_name,
+                                        notification_sentence=self.notification_sentence,
+                                        diff_url=diff_url,
+                                        item_url=item_url,
+                                        comment=comment,
+                                        content_diff_=content_diff,
+                                        meta_diff_=meta_diff,
+                                        unsubscribe_url=unsubscribe_url,
+        )
+        return txt_template, html_template
+
+
+def get_item_last_revisions(app, item_name):
+    """ Get 2 or less most recent item revisions from the index
+
+    :param app: local proxy app
+    :param item_name: the name of the item
+    :return: a list of revisions
+    """
+    terms = [Term(WIKINAME, app.cfg.interwikiname), Term(NAME_EXACT, item_name), ]
+    query = And(terms)
+    return list(
+        flaskg.storage.search(query, idx_name=ALL_REVS, sortedby=[MTIME],
+                              reverse=True, limit=2))
+
+
+@item_modified.connect_via(ANY)
+def send_notifications(app, item_name, **kwargs):
+    """ Send mail notifications to subscribers on item change
+
+    :param app: local proxy app
+    :param item_name: name of the changed item
+    :param kwargs: key/value pairs that contain extra information about the item
+                   required in order to create a notification
+    """
+    action = kwargs.get('action')
+    revs = get_item_last_revisions(app, item_name) if action not in [
+        DESTROY_REV, DESTROY_ALL, ] else []
+    notification = Notification(app, item_name, revs, **kwargs)
+    content_diff = notification.get_content_diff()
+    meta_diff = notification.get_meta_diff()
+
+    u = flaskg.user
+    meta = kwargs.get('meta') if action in [DESTROY_REV, DESTROY_ALL, ] else revs[0].meta._meta
+    subscribers = {subscriber for subscriber in get_subscribers(**meta) if
+                   subscriber.itemid != u.itemid}
+    subscribers_locale = {subscriber.locale for subscriber in subscribers}
+    for locale in subscribers_locale:
+        with force_locale(locale):
+            txt_msg, html_msg = notification.render_templates(content_diff, meta_diff)
+            subject = L_('[%(moin_name)s] Update of "%(item_name)s" by %(user_name)s',
+                         moin_name=app.cfg.interwikiname, item_name=item_name, user_name=u.name0)
+            subscribers_emails = [subscriber.email for subscriber in subscribers
+                                  if subscriber.locale == locale]
+            sendmail(subject, txt_msg, to=subscribers_emails, html=html_msg)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/subscriptions.py	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,91 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - Subscriptions
+"""
+
+import re
+from collections import namedtuple
+from itertools import chain
+
+from flask import g as flaskg
+
+from whoosh.query import Term, Or
+
+from MoinMoin.constants.keys import (DEFAULT_LOCALE, EMAIL, EMAIL_UNVALIDATED, ITEMID,
+                                     LATEST_REVS, LOCALE, NAME, NAMERE, NAMEPREFIX,
+                                     NAMESPACE, SUBSCRIPTION_IDS, SUBSCRIPTION_PATTERNS, TAGS)
+from MoinMoin import log
+logging = log.getLogger(__name__)
+
+
+Subscriber = namedtuple('Subscriber', [ITEMID, NAME, EMAIL, LOCALE])
+
+
+def get_subscribers(**meta):
+    """ Get all users that are subscribed to the item
+
+    :param meta: key/value pairs from item metadata - itemid, name, namespace, tags keys
+    :return: a set of Subscriber objects
+    """
+    itemid = meta.get(ITEMID)
+    name = meta.get(NAME)
+    namespace = meta.get(NAMESPACE)
+    tags = meta.get(TAGS)
+    terms = []
+    if itemid is not None:
+        terms.extend([Term(SUBSCRIPTION_IDS, "{0}:{1}".format(ITEMID, itemid))])
+    if namespace is not None:
+        if name is not None:
+            terms.extend(Term(SUBSCRIPTION_IDS, "{0}:{1}:{2}".format(NAME, namespace, name_))
+                         for name_ in name)
+        if tags is not None:
+            terms.extend(Term(SUBSCRIPTION_IDS, "{0}:{1}:{2}".format(TAGS, namespace, tag))
+                         for tag in tags)
+    query = Or(terms)
+    with flaskg.storage.indexer.ix[LATEST_REVS].searcher() as searcher:
+        result_iterators = [searcher.search(query, limit=None), ]
+        subscription_patterns = searcher.lexicon(SUBSCRIPTION_PATTERNS)
+        patterns = get_matched_subscription_patterns(subscription_patterns, **meta)
+        result_iterators.extend(searcher.documents(subscription_patterns=pattern) for pattern in patterns)
+        subscribers = set()
+        for user in chain.from_iterable(result_iterators):
+            email = user.get(EMAIL)
+            if email:
+                from MoinMoin.user import User
+                u = User(uid=user.get(ITEMID))
+                if u.may.read(name):
+                    locale = user.get(LOCALE, DEFAULT_LOCALE)
+                    subscribers.add(Subscriber(user[ITEMID], user[NAME][0], email, locale))
+    return subscribers
+
+
+def get_matched_subscription_patterns(subscription_patterns, **meta):
+    """ Get all the subscriptions with patterns that match at least one of item names
+
+    :param subscription_patterns: a list of subscription patterns (the ones that
+                                    start with NAMERE or NAMEPREFIX)
+    :param meta: key/value pairs from item metadata - name and namespace keys
+    :return: a list of matched subscription patterns
+    """
+    item_names = meta.get(NAME)
+    item_namespace = meta.get(NAMESPACE)
+    matched_subscriptions = []
+    for subscription in subscription_patterns:
+        keyword, value = subscription.split(":", 1)
+        if keyword in (NAMEPREFIX, NAMERE, ) and item_namespace is not None and item_names:
+            namespace, pattern = value.split(":", 1)
+            if item_namespace == namespace:
+                if keyword == NAMEPREFIX:
+                    if any(name.startswith(pattern) for name in item_names):
+                        matched_subscriptions.append(subscription)
+                elif keyword == NAMERE:
+                    try:
+                        pattern = re.compile(pattern, re.U)
+                    except re.error:
+                        logging.error("Subscription pattern '{0}' has failed compilation.".format(pattern))
+                        continue
+                    if any(pattern.search(name) for name in item_names):
+                        matched_subscriptions.append(subscription)
+    return matched_subscriptions
--- a/docs/admin/configure.rst	Thu Oct 03 15:12:00 2013 +0200
+++ b/docs/admin/configure.rst	Tue Oct 15 21:04:06 2013 -0700
@@ -1280,6 +1280,8 @@
 * might be useful together with SMBMount pseudo-authenticator
 
 
+.. _mail-configuration:
+
 Mail configuration
 ==================
 
@@ -1287,15 +1289,17 @@
 --------------
 Moin can optionally send E-Mail. Possible uses:
 
-* send out item change notifications.
+* send out item change notifications
 * enable users to reset forgotten passwords
+* inform admins about runtime exceptions
 
 You need to configure some settings before sending E-Mail can be supported::
 
     # the "from:" address [Unicode]
     mail_from = u"wiki <wiki@example.org>"
 
-    # a) using an SMTP server, e.g. "mail.provider.com" (None to disable mail)
+    # a) using an SMTP server, e.g. "mail.provider.com" with optional `:port`
+    appendix, which defaults to 25 (set None to disable mail)
     mail_smarthost = "smtp.example.org"
 
     # if you need to use SMTP AUTH at your mail_smarthost:
@@ -1310,6 +1314,19 @@
 
    describe more moin configuration
 
+Admin Traceback E-Mails
+-----------------------
+If you want to enable admins to receive Python tracebacks, you need to configure
+the following::
+
+    # list of admin emails
+    admin_emails = [u"admin <admin@example.org>"]
+
+    # send tracebacks to admins
+    email_tracebacks = True
+
+
+Please also check the logging configuration example in `docs/examples/config/logging/email`.
 
 User E-Mail Address Verification
 --------------------------------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/examples/config/logging/email	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,36 @@
+[DEFAULT]
+# List of admin emails, if left blank will extract the list from wikiconfig ADMIN_EMAILS
+admins=[]
+
+# Email subject, if left blank will display the default subject: [Sitename][Loglevel] Log message
+subject=u""
+
+# Default loglevel, to adjust verbosity: DEBUG, INFO, WARNING, ERROR, CRITICAL
+loglevel=ERROR
+
+# Email loglevel
+emailloglevel=ERROR
+
+[loggers]
+keys=root
+
+[handlers]
+keys=email
+
+[formatters]
+keys=mail
+
+[logger_root]
+level=%(loglevel)s
+handlers=email
+
+[handler_email]
+class=MoinMoin.log.EmailHandler
+level=%(emailloglevel)s
+formatter=default
+args=(%(admins)s, %(subject)s)
+
+[formatter_mail]
+format=%(asctime)s %(levelname)s %(name)s:%(lineno)d %(message)s
+datefmt=
+class=logging.Formatter
\ No newline at end of file
--- a/docs/index.rst	Thu Oct 03 15:12:00 2013 +0200
+++ b/docs/index.rst	Tue Oct 15 21:04:06 2013 -0700
@@ -24,6 +24,7 @@
    user/accounts
    user/markups
    user/search
+   user/subscriptions
 
 Administrating MoinMoin
 =======================
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/user/subscriptions.rst	Tue Oct 15 21:04:06 2013 -0700
@@ -0,0 +1,69 @@
+==================
+User subscriptions
+==================
+
+Users can subscribe to moin items in order to receive notifications about item
+changes. Item changes include:
+
+ * creation of a new item
+ * modification of an existing item
+ * renaming of an item
+ * reverting an item's revision
+ * copying of an item
+ * deletion of an item
+ * destruction of a revision
+ * destruction of all item's revisions
+
+Make sure that Moin is able to send E-Mails, see :ref:`mail-configuration`.
+
+Types of subscriptions
+======================
+
+There are 5 types of subscriptions:
+
+ * by itemid (`itemid:<itemid value>`)
+
+   This is the most common subscription to a single item. The user will be notified
+   even after the item is renamed, because itemid doesn't change. If you click on
+   *Subscribe* on item's page, then you will be subscribed using this type.
+ * by item name (`name:<namespace>:<name value>`),
+
+   The user will be notified, if the name matches any of the item names and also
+   its namespace. Keep in mind that an item can be renamed and notifications for
+   this item would stop working if the new name doesn't match any more.
+ * by tag name (`tags:<namespace>:<tag value>`)
+
+   The user will be notified, if the tag name matches any of the item tags and
+   its namespace.
+ * by a prefix name (`nameprefix:<namespace>:<name prefix>`)
+
+   Used for subscription to a set of items. The user will be notified, if at least
+   one of the item names starts with the given prefix and matches item's namespace.
+   For example if you want to receive notifications about all the items from the
+   default namespace whose name starts with `foo`, you can use `nameprefix::foo`.
+ * by a regular expression (`namere:<namespace>:<name regexp>`)
+
+   Used for subscription to a set of items. The user will be notified, if the
+   regexp matches any of the item names and also its namespace. For example,
+   if you want to receive notifications about all the items on wiki from the default
+   namespace, then you can use `namere::.*`
+
+
+Editing subscriptions
+=====================
+
+The itemid subscription is the most common one and will be used if you click on
+*Subscribe* on item's page. Respectively the *Unsubscribe* will remove the itemid
+subscription.
+
+If you were subscribed to an item by some other way rather than itemid subscription,
+then on *Unsubscribe* you will be told that it is impossible to remove the subscription
+and you need to edit it manually in the User Settings.
+
+All the subscriptions can be added/edited/removed in the User Settings,
+Subscriptions tab. Each subscription is listed on a single line and is
+case-sensitive. Empty lines are ignored.
+
+For itemid subscriptions, we additionally show the current first item name in
+parentheses (this is purely for your information, the name is not stored or used
+in any way).
--- a/wikiconfig.py	Thu Oct 03 15:12:00 2013 +0200
+++ b/wikiconfig.py	Tue Oct 15 21:04:06 2013 -0700
@@ -78,6 +78,11 @@
         xs = XStatic(mod, root_url='/static', provider='local', protocol='http')
         serve_files.update([(xs.name, xs.base_dir)])
 
+    # list of admin emails
+    admin_emails = []
+    # send tracebacks to admins
+    email_tracebacks = False
+
 
 MOINCFG = Config  # Flask only likes uppercase stuff
 # Flask settings - see the flask documentation about their meaning