def get_email_ids(message): parent_email_id = None other_email_ids = [] if message.parent_id: parent = Message._byID(message.parent_id, data=True) if parent.email_id: other_email_ids.append(parent.email_id) parent_email_id = parent.email_id if message.first_message: first_message = Message._byID(message.first_message, data=True) if first_message.email_id: other_email_ids.append(first_message.email_id) return parent_email_id, other_email_ids
def get_reply_to_address(message): """Construct a reply-to address that encodes the message id. The address is of the form: zendeskreply+{message_id36}-{email_mac} where the mac is generated from {message_id36} using the `modmail_email_secret` The reply address should be configured with the inbound email service so that replies to our messages are routed back to the app somehow. For mailgun this involves adding a Routes filter for messages sent to "zendeskreply\+*@". to be forwarded to POST /api/zendeskreply. """ # all email replies are treated as replies to the first message in the # conversation. this is to get around some peculiarities of zendesk if message.first_message: first_message = Message._byID(message.first_message, data=True) else: first_message = message email_id = first_message._id36 email_mac = hmac.new( g.secrets['modmail_email_secret'], email_id, hashlib.sha256).hexdigest() reply_id = "zendeskreply+{email_id}-{email_mac}".format( email_id=email_id, email_mac=email_mac) sr = Subreddit._byID(message.sr_id, data=True) return "{brander_community_abbr}/{subreddit} mail <{reply_id}@{domain}>".format( subreddit=sr.name, reply_id=reply_id, domain=g.modmail_email_domain, brander_community_abbr = g.brander_community_abbr)
def __init__(self, thing, delete = False, report = True): was_comment = getattr(thing, 'was_comment', False) permalink = thing.permalink # don't allow replying to self unless it's modmail valid_recipient = (thing.author_id != c.user._id or thing.sr_id) can_reply = (c.user_is_loggedin and getattr(thing, "repliable", True) and valid_recipient) can_block = True can_mute = False is_admin_message = False del_on_recipient = (isinstance(thing, Message) and thing.del_on_recipient) if not was_comment: first_message = thing if getattr(thing, 'first_message', False): first_message = Message._byID(thing.first_message, data=True) if thing.sr_id: sr = thing.subreddit_slow is_admin_message = '/r/%s' % sr.name == g.admin_message_acct if (sr.is_muted(first_message.author_slow) or (first_message.to_id and sr.is_muted(first_message.recipient_slow))): can_reply = False can_mute = sr.can_mute(c.user, thing.author_slow) if not was_comment and thing.display_author: can_block = False if was_comment: link = thing.link_slow if link.archived or link.locked: can_reply = False # Allow comment-reply messages to have links to the full thread. if was_comment: self.full_comment_path = thing.link_permalink self.full_comment_count = thing.full_comment_count PrintableButtons.__init__(self, "messagebuttons", thing, profilepage = c.profilepage, permalink = permalink, was_comment = was_comment, unread = thing.new, user_is_recipient = thing.user_is_recipient, can_reply = can_reply, parent_id = getattr(thing, "parent_id", None), show_report = True, show_delete = False, can_block = can_block, can_mute = can_mute, is_admin_message = is_admin_message, del_on_recipient=del_on_recipient, )
def compute_message_trees(messages): from r2.models import Message roots = set() threads = {} mdict = {} messages = sorted(messages, key = lambda m: m._date, reverse = True) for m in messages: if not m._loaded: m._load() mdict[m._id] = m if m.first_message: roots.add(m.first_message) threads.setdefault(m.first_message, set()).add(m._id) else: roots.add(m._id) # load any top-level messages which are not in the original list missing = [m for m in roots if m not in mdict] if missing: mdict.update(Message._byID(tup(missing), return_dict = True, data = True)) # sort threads in chrono order for k in threads: threads[k] = list(sorted(threads[k])) tree = [(root, threads.get(root, [])) for root in roots] tree.sort(key = tree_sort_fn, reverse = True) return tree
def compute_message_trees(messages): from r2.models import Message roots = set() threads = {} mdict = {} messages = sorted(messages, key=lambda m: m._date, reverse=True) for m in messages: if not m._loaded: m._load() mdict[m._id] = m if m.first_message: roots.add(m.first_message) threads.setdefault(m.first_message, set()).add(m._id) else: roots.add(m._id) # load any top-level messages which are not in the original list missing = [m for m in roots if m not in mdict] if missing: mdict.update(Message._byID(tup(missing), return_dict=True, data=True)) # sort threads in chrono order for k in threads: threads[k] = list(sorted(threads[k])) tree = [(root, threads.get(root, [])) for root in roots] tree.sort(key=tree_sort_fn, reverse=True) return tree
def get_reply_to_address(message): """Construct a reply-to address that encodes the message id. The address is of the form: zendeskreply+{message_id36}-{email_mac} where the mac is generated from {message_id36} using the `modmail_email_secret` The reply address should be configured with the inbound email service so that replies to our messages are routed back to the app somehow. For mailgun this involves adding a Routes filter for messages sent to "zendeskreply\+*@". to be forwarded to POST /api/zendeskreply. """ # all email replies are treated as replies to the first message in the # conversation. this is to get around some peculiarities of zendesk if message.first_message: first_message = Message._byID(message.first_message, data=True) else: first_message = message email_id = first_message._id36 email_mac = hmac.new( g.secrets['modmail_email_secret'], email_id, hashlib.sha256).hexdigest() reply_id = "zendeskreply+{email_id}-{email_mac}".format( email_id=email_id, email_mac=email_mac) sr = Subreddit._byID(message.sr_id, data=True) return "r/{subreddit} mail <{reply_id}@{domain}>".format( subreddit=sr.name, reply_id=reply_id, domain=g.modmail_email_domain)
def get_message_subject(message): sr = Subreddit._byID(message.sr_id, data=True) if message.first_message: first_message = Message._byID(message.first_message, data=True) conversation_subject = first_message.subject else: conversation_subject = message.subject return u"[{brander_community_abbr}/{subreddit} mail]: {subject}".format( subreddit=sr.name, subject=_force_unicode(conversation_subject, brander_community_abbr=g.brander_community_abbr))
def get_message_subject(message): sr = Subreddit._byID(message.sr_id, data=True) if message.first_message: first_message = Message._byID(message.first_message, data=True) conversation_subject = first_message.subject else: conversation_subject = message.subject return u"[r/{subreddit} mail]: {subject}".format( subreddit=sr.name, subject=_force_unicode(conversation_subject))
def __init__(self, thing, delete = False, report = True): was_comment = getattr(thing, 'was_comment', False) permalink = thing.permalink # don't allow replying to self unless it's modmail valid_recipient = (thing.author_id != c.user._id or thing.sr_id) is_muted = False can_mute = False if not was_comment: first_message = thing if getattr(thing, 'first_message', False): first_message = Message._byID(thing.first_message, data=True) if thing.sr_id: sr = thing.subreddit_slow if feature.is_enabled('modmail_muting', subreddit=sr.name): if (sr.is_muted(first_message.author_slow) or (first_message.to_id and sr.is_muted(first_message.recipient_slow))): is_muted = True if (not sr.is_moderator(thing.author_slow) and sr.is_moderator_with_perms(c.user, 'access', 'mail')): can_mute = True can_reply = (c.user_is_loggedin and getattr(thing, "repliable", True) and valid_recipient and not is_muted) can_block = True if not thing.was_comment and thing.display_author: can_block = False # Allow comment-reply messages to have links to the full thread. if was_comment: self.full_comment_path = thing.link_permalink self.full_comment_count = thing.full_comment_count PrintableButtons.__init__(self, "messagebuttons", thing, profilepage = c.profilepage, permalink = permalink, was_comment = was_comment, unread = thing.new, user_is_recipient = thing.user_is_recipient, can_reply = can_reply, parent_id = getattr(thing, "parent_id", None), show_report = True, show_delete = False, can_block = can_block, can_mute = can_mute, )
def _conversation(trees, parent): from r2.models import Message if parent._id in trees: convo = trees[parent._id] if convo: m = Message._byID(convo[0], data=True) if not convo or m.first_message == m.parent_id: return [(parent._id, convo)] # if we get to this point, either we didn't find the conversation, # or the first child of the result was not the actual first child. # To the database! m = Message._query(Message.c.first_message == parent._id, data=True) return compute_message_trees([parent] + list(m))
def _conversation(trees, parent): from r2.models import Message if parent._id in trees: convo = trees[parent._id] if convo: m = Message._byID(convo[0], data=True) if not convo or m.first_message == m.parent_id: return [(parent._id, convo)] # if we get to this point, either we didn't find the conversation, # or the first child of the result was not the actual first child. # To the database! rules = [Message.c.first_message == parent._id] if c.user_is_admin: rules.append(Message.c._spam == (True, False)) rules.append(Message.c._deleted == (True, False)) m = Message._query(*rules, data=True) return compute_message_trees([parent] + list(m))
def _conversation(trees, parent): from r2.models import Message if parent._id in trees: convo = trees[parent._id] if convo: m = Message._byID(convo[0], data = True) if not convo or m.first_message == m.parent_id: return [(parent._id, convo)] # if we get to this point, either we didn't find the conversation, # or the first child of the result was not the actual first child. # To the database! rules = [Message.c.first_message == parent._id] if c.user_is_admin: rules.append(Message.c._spam == (True, False)) rules.append(Message.c._deleted == (True, False)) m = Message._query(*rules, data=True) return compute_message_trees([parent] + list(m))
def get_items(self): tree = self.get_tree() prev_item = next_item = None if not self.parent: if self.num is not None: if self.after: if self.reverse: tree = filter( self._tree_filter_reverse, tree) next_item = self.after._id if len(tree) > self.num: first = tree[-(self.num+1)] prev_item = first[1][-1] if first[1] else first[0] tree = tree[-self.num:] else: prev_item = self.after._id tree = filter( self._tree_filter, tree) if len(tree) > self.num: tree = tree[:self.num] last = tree[-1] next_item = last[1][-1] if last[1] else last[0] # generate the set of ids to look up and look them up message_ids = [] for root, thread in tree: message_ids.append(root) message_ids.extend(thread) if prev_item: message_ids.append(prev_item) messages = Message._byID(message_ids, data = True, return_dict = False) wrapped = {} for m in self.wrap_items(messages): if not self._viewable_message(m): g.log.warning("%r is not viewable by %s; path is %s" % (m, c.user.name, request.fullpath)) continue wrapped[m._id] = m if prev_item: prev_item = wrapped[prev_item] if next_item: next_item = wrapped[next_item] final = [] for parent, children in tree: if parent not in wrapped: continue parent = wrapped[parent] if children: # if no parent is specified, check if any of the messages are # uncollapsed, and truncate the thread children = [wrapped[child] for child in children if child in wrapped] parent.child = empty_listing() # if the parent is new, uncollapsed, or focal we don't # want it to become a moremessages wrapper. if (self.skip and not self.parent and not parent.new and parent.is_collapsed and not (self.focal and self.focal._id == parent._id)): for i, child in enumerate(children): if (child.new or not child.is_collapsed or (self.focal and self.focal._id == child._id)): break else: i = -1 parent = Wrapped(MoreMessages(parent, empty_listing())) children = children[i:] parent.child.parent_name = parent._fullname parent.child.things = [] for child in children: child.is_child = True if self.focal and child._id == self.focal._id: # focal message is never collapsed child.collapsed = False child.focal = True else: child.collapsed = child.is_collapsed parent.child.things.append(child) parent.is_parent = True # the parent might be the focal message on a permalink page if self.focal and parent._id == self.focal._id: parent.collapsed = False parent.focal = True else: parent.collapsed = parent.is_collapsed final.append(parent) return (final, prev_item, next_item, len(final), len(final))
def get_items(self): tree = self.get_tree() tree, prev_item, next_item = self._apply_pagination(tree) message_ids = [] for parent_id, child_ids in tree: message_ids.append(parent_id) message_ids.extend(child_ids) if prev_item: message_ids.append(prev_item) messages = Message._byID(message_ids, data=True, return_dict=False) wrapped = {m._id: m for m in self.wrap_items(messages)} if prev_item: prev_item = wrapped[prev_item] if next_item: next_item = wrapped[next_item] final = [] for parent_id, child_ids in tree: if parent_id not in wrapped: continue parent = wrapped[parent_id] if not self._viewable_message(parent): continue children = [wrapped[child_id] for child_id in child_ids if child_id in wrapped] depth = {parent_id: 0} substitute_parents = {} if ( children and self.skip and not self.threaded and not self.parent and not parent.new and parent.is_collapsed ): for i, child in enumerate(children): if child.new or not child.is_collapsed: break else: i = -1 # in flat view replace collapsed chain with MoreMessages add_child_listing(parent) parent = Wrapped(MoreMessages(parent, parent.child)) children = children[i:] for child in sorted(children, key=lambda child: child._id): # iterate from the root outwards so we can check the depth if self.threaded: try: child_parent = wrapped[child.parent_id] except KeyError: # the stored comment tree was missing this message's # parent, treat it as a top level reply child_parent = parent else: # for flat view all messages are decendants of the # parent message child_parent = parent parent_depth = depth[child_parent._id] child_depth = parent_depth + 1 depth[child._id] = child_depth if child_depth == MAX_RECURSION: # current message is at maximum depth level, all its # children will be displayed as children of its parent substitute_parents[child._id] = child_parent._id if child_depth > MAX_RECURSION: child_parent_id = substitute_parents[child.parent_id] substitute_parents[child._id] = child_parent_id child_parent = wrapped[child_parent_id] if not hasattr(child_parent, "child"): add_child_listing(child_parent) child.is_child = True child_parent.child.things.append(child) for child in children: # look over the children again to decide whether they can be # collapsed child.threaded = self.threaded child.collapsed = self.should_collapse(child) if self.threaded and children: most_recent_child_id = max(child._id for child in children) most_recent_child = wrapped[most_recent_child_id] most_recent_child.most_recent = True parent.is_parent = True parent.threaded = self.threaded parent.collapsed = self.should_collapse(parent) final.append(parent) return (final, prev_item, next_item, len(final), len(final))
def get_items(self): tree = self.get_tree() prev_item = next_item = None if not self.parent: if self.num is not None: if self.after: if self.reverse: tree = filter(self._tree_filter_reverse, tree) next_item = self.after._id if len(tree) > self.num: first = tree[-(self.num + 1)] prev_item = first[1][-1] if first[1] else first[0] tree = tree[-self.num:] else: prev_item = self.after._id tree = filter(self._tree_filter, tree) if len(tree) > self.num: tree = tree[:self.num] last = tree[-1] next_item = last[1][-1] if last[1] else last[0] # generate the set of ids to look up and look them up message_ids = [] for root, thread in tree: message_ids.append(root) message_ids.extend(thread) if prev_item: message_ids.append(prev_item) messages = Message._byID(message_ids, data=True, return_dict=False) wrapped = {m._id: m for m in self.wrap_items(messages)} if prev_item: prev_item = wrapped[prev_item] if next_item: next_item = wrapped[next_item] final = [] for parent, children in tree: if parent not in wrapped: continue parent = wrapped[parent] if not self._viewable_message(parent): continue if children: # if no parent is specified, check if any of the messages are # uncollapsed, and truncate the thread children = [ wrapped[child] for child in children if child in wrapped ] add_child_listing(parent) # if the parent is new, uncollapsed, or focal we don't # want it to become a moremessages wrapper. if (self.skip and not self.parent and not parent.new and parent.is_collapsed and not (self.focal and self.focal._id == parent._id)): for i, child in enumerate(children): if (child.new or not child.is_collapsed or (self.focal and self.focal._id == child._id)): break else: i = -1 parent = Wrapped(MoreMessages(parent, parent.child)) children = children[i:] parent.child.parent_name = parent._fullname parent.child.things = [] for child in children: child.is_child = True if self.focal and child._id == self.focal._id: # focal message is never collapsed child.collapsed = False child.focal = True else: child.collapsed = child.is_collapsed parent.child.things.append(child) parent.is_parent = True # the parent might be the focal message on a permalink page if self.focal and parent._id == self.focal._id: parent.collapsed = False parent.focal = True else: parent.collapsed = parent.is_collapsed final.append(parent) return (final, prev_item, next_item, len(final), len(final))
def modmail_event(self, message, request=None, context=None): """Create a 'modmail' event for event-collector. message: An r2.models.Message object request: pylons.request of the request that created the message context: pylons.tmpl_context of the request that created the message """ from r2.models import Account, Message sender = message.author_slow sr = message.subreddit_slow sender_is_moderator = sr.is_moderator_with_perms(sender, "mail") if message.first_message: first_message = Message._byID(message.first_message, data=True) else: first_message = message event = EventV2( topic="message_events", event_type="ss.send_message", time=message._date, request=request, context=context, data={ # set these manually rather than allowing them to be set from # the request context because the loggedin user might not # be the message sender "user_id": sender._id, "user_name": sender.name, }, ) if sender == Account.system_user(): sender_type = "automated" elif sender_is_moderator: sender_type = "moderator" else: sender_type = "user" event.add("sender_type", sender_type) event.add("sr_id", sr._id) event.add("sr_name", sr.name) event.add("message_id", message._id) event.add("message_fullname", message._fullname) event.add("first_message_id", first_message._id) event.add("first_message_fullname", first_message._fullname) if request and request.POST.get("source", None): source = request.POST["source"] if source in {"compose", "permalink", "modmail", "usermail"}: event.add("page", source) if message.sent_via_email: event.add("is_third_party", True) event.add("third_party_metadata", "mailgun") if not message.to_id: target = sr else: target = Account._byID(message.to_id, data=True) event.add_target_fields(target) self.save_event(event)
def message_event(self, message, request=None, context=None): """Create a 'message' event for event-collector. message: An r2.models.Message object request: pylons.request of the request that created the message context: pylons.tmpl_context of the request that created the message """ from r2.models import Account, Message sender = message.author_slow if message.first_message: first_message = Message._byID(message.first_message, data=True) else: first_message = message event = Event( topic="message_events", event_type="ss.send_message", time=message._date, request=request, context=context, data={ # set these manually rather than allowing them to be set from # the request context because the loggedin user might not # be the message sender "user_id": sender._id, "user_name": sender.name, }, ) if sender == Account.system_user(): sender_type = "automated" else: sender_type = "user" event.add("sender_type", sender_type) event.add("message_kind", "message") event.add("message_id", message._id) event.add("message_fullname", message._fullname) event.add_text("message_body", message.body) event.add_text("message_subject", message.subject) event.add("first_message_id", first_message._id) event.add("first_message_fullname", first_message._fullname) if request and request.POST.get("source", None): source = request.POST["source"] if source in {"compose", "permalink", "usermail"}: event.add("page", source) if message.sent_via_email: event.add("is_third_party", True) event.add("third_party_metadata", "mailgun") target = Account._byID(message.to_id, data=True) event.add_target_fields(target) self.save_event(event)
def POST_mod_messages(self, conversation, msg_body, is_author_hidden, is_internal): """Creates a new message for a particular ModmailConversation URL Params: conversation_id -- id of the conversation to post a new message to POST Params: body -- this is the message body isAuthorHidden -- boolean on whether to hide author, i.e. respond as the subreddit isInternal -- boolean to signify a moderator only message """ self._validate_vmodconversation() sr = Subreddit._by_fullname(conversation.owner_fullname) self._feature_enabled_check(sr) # make sure the user is not muted before posting a message if sr.is_muted(c.user): return self.send_error(400, errors.USER_MUTED) if conversation.is_internal and not is_internal: is_internal = True is_mod = sr.is_moderator(c.user) if not is_mod and is_author_hidden: return self.send_error( 403, errors.MOD_REQUIRED, fields='isAuthorHidden', ) elif not is_mod and is_internal: return self.send_error( 403, errors.MOD_REQUIRED, fields='isInternal', ) try: if not conversation.is_internal and not conversation.is_auto: participant = conversation.get_participant_account() if participant and sr.is_muted(participant): return self.send_error( 400, errors.MUTED_FROM_SUBREDDIT, ) except NotFound: pass try: new_message = conversation.add_message( c.user, msg_body, is_author_hidden=is_author_hidden, is_internal=is_internal, ) except: return self.send_error(500, errors.MODMAIL_MESSAGE_NOT_SAVED) # Add the message to the legacy messaging system as well (unless it's # an internal message on a non-internal conversation, since we have no # way to hide specific messages from the external participant) legacy_incompatible = is_internal and not conversation.is_internal if (conversation.legacy_first_message_id and not legacy_incompatible): first_message = Message._byID(conversation.legacy_first_message_id) subject = conversation.subject if not subject.startswith('re: '): subject = 're: ' + subject # Retrieve the participant to decide whether to send the message # to the sr or to the participant. If the currently logged in user # is the same as the participant then address the message to the # sr. recipient = sr if not is_internal: try: participant = ( ModmailConversationParticipant.get_participant( conversation.id)) is_participant = ( (c.user._id == participant.account_id) and not sr.is_moderator_with_perms(c.user, 'mail')) if not is_participant: recipient = Account._byID(participant.account_id) except NotFound: pass message, inbox_rel = Message._new( c.user, recipient, subject, msg_body, request.ip, parent=first_message, from_sr=is_author_hidden, create_modmail=False, ) queries.new_message(message, inbox_rel) serializable_convo = conversation.to_serializable( entity=sr, all_messages=True, current_user=c.user, ) messages = serializable_convo.pop('messages') g.events.new_modmail_event( 'ss.send_modmail_message', conversation, message=new_message, msg_author=c.user, sr=sr, request=request, context=c, ) response.status_code = 201 return simplejson.dumps({ 'conversation': serializable_convo, 'messages': messages, })