From 2b9cb67e1fff4a46e2b52d0f086affecb04a71b2 Mon Sep 17 00:00:00 2001 From: Anand Doshi Date: Tue, 15 Sep 2015 19:25:09 +0530 Subject: [PATCH] Added CC in Communication to manually specify whom to notify. frappe/erpnext#3697 --- frappe/__init__.py | 6 +- .../doctype/communication/communication.json | 218 ++++++++-------- .../doctype/communication/communication.py | 233 ++++++++++++------ frappe/email/bulk.py | 87 ++++--- .../doctype/email_account/email_account.py | 6 +- frappe/email/email_body.py | 10 +- frappe/public/js/frappe/misc/user.js | 23 ++ frappe/public/js/frappe/misc/utils.js | 3 + frappe/public/js/frappe/ui/star.js | 9 +- .../public/js/frappe/views/communication.js | 164 +++++++++--- frappe/tasks.py | 6 +- frappe/utils/__init__.py | 10 +- 12 files changed, 506 insertions(+), 269 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index ea7432f562..d00a3e64e6 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -309,7 +309,7 @@ def sendmail(recipients=(), sender="", subject="No Subject", message="No Message as_markdown=False, bulk=False, reference_doctype=None, reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, attachments=None, content=None, doctype=None, name=None, reply_to=None, - cc=(), message_id=None, as_bulk=False, send_after=None): + cc=(), message_id=None, as_bulk=False, send_after=None, expose_recipients=False): """Send email using user's default **Email Account** or global default **Email Account**. @@ -327,6 +327,7 @@ def sendmail(recipients=(), sender="", subject="No Subject", message="No Message :param reply_to: Reply-To email id. :param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email. :param send_after: Send after the given datetime. + :param expose_recipients: Display all recipients in the footer message - "This email was sent to" """ if bulk or as_bulk: @@ -335,7 +336,8 @@ def sendmail(recipients=(), sender="", subject="No Subject", message="No Message subject=subject, message=content or message, reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message, - attachments=attachments, reply_to=reply_to, cc=cc, message_id=message_id, send_after=send_after) + attachments=attachments, reply_to=reply_to, cc=cc, message_id=message_id, send_after=send_after, + expose_recipients=expose_recipients) else: import frappe.email if as_markdown: diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index 31b263e9c5..16627d67fa 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -37,20 +37,21 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "sent_or_received", + "depends_on": "", + "fieldname": "communication_medium", "fieldtype": "Select", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 1, - "label": "Sent or Received", + "label": "Communication Medium", "no_copy": 0, - "options": "Sent\nReceived", + "options": "\nChat\nPhone\nEmail\nSMS\nVisit\nOther", "permlevel": 0, "print_hide": 0, "read_only": 0, "report_hide": 0, - "reqd": 1, + "reqd": 0, "search_index": 0, "set_only_once": 0, "unique": 0 @@ -59,17 +60,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "status", - "fieldtype": "Select", + "fieldname": "recipients", + "fieldtype": "Data", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, - "in_list_view": 1, - "label": "Status", + "in_list_view": 0, + "label": "Recipients", "no_copy": 0, - "options": "Open\nReplied\nClosed\nLinked", "permlevel": 0, - "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, @@ -82,16 +81,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "description": "Integrations can use this field to set email delivery status", - "fieldname": "delivery_status", - "fieldtype": "Select", - "hidden": 1, + "depends_on": "eval:doc.communication_medium===\"Email\"", + "fieldname": "cc", + "fieldtype": "Data", + "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Delivery Status", + "label": "CC", "no_copy": 0, - "options": "\nSent\nBounced\nOpened\nMarked As Spam\nRejected\nDelayed\nSoft-Bounced\nClicked\nRecipient Unsubscribed", "permlevel": 0, "precision": "", "print_hide": 0, @@ -106,19 +104,20 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "subject", + "depends_on": "eval:doc.communication_medium!==\"Email\"", + "fieldname": "phone_no", "fieldtype": "Data", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Subject", + "label": "Phone No.", "no_copy": 0, "permlevel": 0, "print_hide": 0, "read_only": 0, "report_hide": 0, - "reqd": 1, + "reqd": 0, "search_index": 0, "set_only_once": 0, "unique": 0 @@ -148,15 +147,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "reference_doctype", - "fieldtype": "Link", + "fieldname": "status", + "fieldtype": "Select", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, - "in_list_view": 0, - "label": "Reference DocType", + "in_list_view": 1, + "label": "Status", "no_copy": 0, - "options": "DocType", + "options": "Open\nReplied\nClosed\nLinked", "permlevel": 0, "precision": "", "print_hide": 0, @@ -171,21 +170,20 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "reference_name", - "fieldtype": "Dynamic Link", + "fieldname": "sent_or_received", + "fieldtype": "Select", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, - "in_list_view": 0, - "label": "Reference Name", + "in_list_view": 1, + "label": "Sent or Received", "no_copy": 0, - "options": "reference_doctype", + "options": "Sent\nReceived", "permlevel": 0, - "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, - "reqd": 0, + "reqd": 1, "search_index": 0, "set_only_once": 0, "unique": 0 @@ -194,13 +192,16 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "section_break_8", - "fieldtype": "Section Break", - "hidden": 0, + "description": "Integrations can use this field to set email delivery status", + "fieldname": "delivery_status", + "fieldtype": "Select", + "hidden": 1, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, + "label": "Delivery Status", "no_copy": 0, + "options": "\nSent\nBounced\nOpened\nMarked As Spam\nRejected\nDelayed\nSoft-Bounced\nClicked\nRecipient Unsubscribed", "permlevel": 0, "precision": "", "print_hide": 0, @@ -215,37 +216,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "content", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Content", - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, - "width": "400" - }, - { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "additional_info", + "fieldname": "section_break_10", "fieldtype": "Section Break", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Additional Info", "no_copy": 0, "permlevel": 0, + "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, @@ -258,19 +237,19 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "recipients", + "fieldname": "subject", "fieldtype": "Data", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Recipients", + "label": "Subject", "no_copy": 0, "permlevel": 0, "print_hide": 0, "read_only": 0, "report_hide": 0, - "reqd": 0, + "reqd": 1, "search_index": 0, "set_only_once": 0, "unique": 0 @@ -279,15 +258,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "phone_no", - "fieldtype": "Data", + "fieldname": "section_break_8", + "fieldtype": "Section Break", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Phone No.", "no_copy": 0, "permlevel": 0, + "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, @@ -300,15 +279,14 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "communication_medium", - "fieldtype": "Select", + "fieldname": "content", + "fieldtype": "Text Editor", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, - "in_list_view": 1, - "label": "Communication Medium", + "in_list_view": 0, + "label": "Content", "no_copy": 0, - "options": "\nChat\nPhone\nEmail\nSMS\nVisit\nOther", "permlevel": 0, "print_hide": 0, "read_only": 0, @@ -316,21 +294,22 @@ "reqd": 0, "search_index": 0, "set_only_once": 0, - "unique": 0 + "unique": 0, + "width": "400" }, { "allow_on_submit": 0, "bold": 0, - "collapsible": 0, - "fieldname": "column_break_14", - "fieldtype": "Column Break", + "collapsible": 1, + "fieldname": "additional_info", + "fieldtype": "Section Break", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, + "label": "More Information", "no_copy": 0, "permlevel": 0, - "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, @@ -386,14 +365,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "section_break2", - "fieldtype": "Section Break", + "default": "Today", + "fieldname": "communication_date", + "fieldtype": "Datetime", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, + "label": "Date", "no_copy": 0, - "options": "simple", "permlevel": 0, "print_hide": 0, "read_only": 0, @@ -407,15 +387,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "column_break4", + "fieldname": "column_break_14", "fieldtype": "Column Break", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "By", "no_copy": 0, "permlevel": 0, + "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, @@ -428,15 +408,15 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "email_account", + "fieldname": "reference_doctype", "fieldtype": "Link", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Email Account", + "label": "Reference DocType", "no_copy": 0, - "options": "Email Account", + "options": "DocType", "permlevel": 0, "precision": "", "print_hide": 0, @@ -451,19 +431,19 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "default": "__user", - "fieldname": "user", - "fieldtype": "Link", + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", "hidden": 0, - "ignore_user_permissions": 1, + "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "User", + "label": "Reference Name", "no_copy": 0, - "options": "User", + "options": "reference_doctype", "permlevel": 0, + "precision": "", "print_hide": 0, - "read_only": 1, + "read_only": 0, "report_hide": 0, "reqd": 0, "search_index": 0, @@ -474,17 +454,19 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "column_break5", - "fieldtype": "Column Break", + "fieldname": "in_reply_to", + "fieldtype": "Link", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "On", + "label": "In Reply To", "no_copy": 0, + "options": "Communication", "permlevel": 0, + "precision": "", "print_hide": 0, - "read_only": 0, + "read_only": 1, "report_hide": 0, "reqd": 0, "search_index": 0, @@ -495,16 +477,17 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "default": "Today", - "fieldname": "communication_date", - "fieldtype": "Datetime", + "fieldname": "email_account", + "fieldtype": "Link", "hidden": 0, "ignore_user_permissions": 0, "in_filter": 0, "in_list_view": 0, - "label": "Date", + "label": "Email Account", "no_copy": 0, + "options": "Email Account", "permlevel": 0, + "precision": "", "print_hide": 0, "read_only": 0, "report_hide": 0, @@ -517,17 +500,19 @@ "allow_on_submit": 0, "bold": 0, "collapsible": 0, - "fieldname": "_user_tags", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, + "default": "__user", + "fieldname": "user", + "fieldtype": "Link", + "hidden": 0, + "ignore_user_permissions": 1, "in_filter": 0, "in_list_view": 0, - "label": "User Tags", - "no_copy": 1, + "label": "User", + "no_copy": 0, + "options": "User", "permlevel": 0, - "print_hide": 1, - "read_only": 0, + "print_hide": 0, + "read_only": 1, "report_hide": 0, "reqd": 0, "search_index": 0, @@ -556,6 +541,27 @@ "search_index": 0, "set_only_once": 0, "unique": 0 + }, + { + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "fieldname": "_user_tags", + "fieldtype": "Data", + "hidden": 1, + "ignore_user_permissions": 0, + "in_filter": 0, + "in_list_view": 0, + "label": "User Tags", + "no_copy": 1, + "permlevel": 0, + "print_hide": 1, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 } ], "hide_heading": 0, @@ -567,7 +573,7 @@ "is_submittable": 0, "issingle": 0, "istable": 0, - "modified": "2015-08-14 17:46:20.902296", + "modified": "2015-09-15 05:51:16.112080", "modified_by": "Administrator", "module": "Core", "name": "Communication", diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index da81b7fe15..08cd37df2a 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals, absolute_import import frappe import json from email.utils import formataddr, parseaddr -from frappe.utils import get_url, get_formatted_email, cstr, cint +from frappe.utils import get_url, get_formatted_email, cstr, cint, validate_email_add, split_emails from frappe.utils.file_manager import get_file import frappe.email.smtp from frappe import _ @@ -33,6 +33,14 @@ class Communication(Document): else: self.status = "Open" + # validate recipients + for email in split_emails(self.recipients): + validate_email_add(email, throw=True) + + # validate CC + for email in split_emails(self.cc): + validate_email_add(email, throw=True) + def after_insert(self): # send new comment to listening clients comment = self.as_dict() @@ -73,51 +81,41 @@ class Communication(Document): self.send_me_a_copy = send_me_a_copy self.notify(print_html, print_format, attachments, recipients) - def set_incoming_outgoing_accounts(self): - self.incoming_email_account = self.outgoing_email_account = None - - if self.reference_doctype: - self.incoming_email_account = frappe.db.get_value("Email Account", - {"append_to": self.reference_doctype, "enable_incoming": 1}, "email_id") - - self.outgoing_email_account = frappe.db.get_value("Email Account", - {"append_to": self.reference_doctype, "enable_outgoing": 1}, - ["email_id", "always_use_account_email_id_as_sender"], as_dict=True) - - if not self.incoming_email_account: - self.incoming_email_account = frappe.db.get_value("Email Account", {"default_incoming": 1}, "email_id") - - if not self.outgoing_email_account: - self.outgoing_email_account = frappe.db.get_value("Email Account", {"default_outgoing": 1}, - ["email_id", "always_use_account_email_id_as_sender"], as_dict=True) or frappe._dict() - - def notify(self, print_html=None, print_format=None, attachments=None, recipients=None, except_recipient=False): + def notify(self, print_html=None, print_format=None, attachments=None, + recipients=None, cc=None, fetched_from_email_account=False): """Calls a delayed celery task 'sendmail' that enqueus email in Bulk Email queue :param print_html: Send given value as HTML attachment :param print_format: Attach print format of parent document :param attachments: A list of filenames that should be attached when sending this email :param recipients: Email recipients - :param except_recipient: True when pulling email, the notification shouldn't go to the main recipient + :param cc: Send email as CC to + :param fetched_from_email_account: True when pulling email, the notification shouldn't go to the main recipient """ + recipients, cc = self.get_recipients_and_cc(recipients, cc, + fetched_from_email_account=fetched_from_email_account) + + self.emails_not_sent_to = set(self.all_email_addresses) - set(recipients) - set(cc) + if frappe.flags.in_test: # for test cases, run synchronously self._notify(print_html=print_html, print_format=print_format, attachments=attachments, - recipients=recipients, except_recipient=except_recipient) + recipients=recipients, cc=cc) else: from frappe.tasks import sendmail sendmail.delay(frappe.local.site, self.name, print_html=print_html, print_format=print_format, attachments=attachments, - recipients=recipients, except_recipient=except_recipient) + recipients=recipients, cc=cc) + + def _notify(self, print_html=None, print_format=None, attachments=None, + recipients=None, cc=None): - def _notify(self, print_html=None, print_format=None, attachments=None, recipients=None, except_recipient=False): self.prepare_to_notify(print_html, print_format, attachments) - if not recipients: - recipients = self.get_recipients(except_recipient=except_recipient) frappe.sendmail( - recipients=recipients, + recipients=(recipients or []) + (cc or []), + expose_recipients=True, sender=self.sender, reply_to=self.incoming_email_account, subject=self.subject, @@ -130,6 +128,27 @@ class Communication(Document): bulk=True ) + def get_recipients_and_cc(self, recipients, cc, fetched_from_email_account=False): + self.all_email_addresses = [] + + if not recipients: + recipients = self.get_recipients() + + if not cc: + cc = self.get_cc(recipients, fetched_from_email_account=fetched_from_email_account) + + if fetched_from_email_account: + # email was already sent to the original recipient by the sender's email service + original_recipients, recipients = recipients, [] + + # cc that was received in the email + original_cc = split_emails(self.cc) + + # don't cc to people who already received the mail from sender's email service + cc = list(set(cc) - set(original_cc) - set(original_recipients)) + + return recipients, cc + def prepare_to_notify(self, print_html=None, print_format=None, attachments=None): """Prepare to make multipart MIME Email @@ -165,78 +184,129 @@ class Communication(Document): else: self.attachments.append(a) - def get_recipients(self, except_recipient=False): - """Build a list of users to which this email should go to""" + def set_incoming_outgoing_accounts(self): + self.incoming_email_account = self.outgoing_email_account = None + + if self.reference_doctype: + self.incoming_email_account = frappe.db.get_value("Email Account", + {"append_to": self.reference_doctype, "enable_incoming": 1}, "email_id") + + self.outgoing_email_account = frappe.db.get_value("Email Account", + {"append_to": self.reference_doctype, "enable_outgoing": 1}, + ["email_id", "always_use_account_email_id_as_sender"], as_dict=True) + + if not self.incoming_email_account: + self.incoming_email_account = frappe.db.get_value("Email Account", {"default_incoming": 1}, "email_id") + + if not self.outgoing_email_account: + self.outgoing_email_account = frappe.db.get_value("Email Account", {"default_outgoing": 1}, + ["email_id", "always_use_account_email_id_as_sender"], as_dict=True) or frappe._dict() + + def get_recipients(self): + """Build a list of email addresses for To""" # [EDGE CASE] self.recipients can be None when an email is sent as BCC - original_recipients = [s.strip() for s in cstr(self.recipients).split(",")] - recipients = original_recipients[:] + recipients = split_emails(self.recipients) + + if recipients: + # this will be used to eventually find email addresses that aren't sent to + self.all_email_addresses.extend(recipients) + + # exclude email accounts + exclude = [d[0] for d in + frappe.db.get_all("Email Account", ["email_id"], {"enable_incoming": 1}, as_list=True)] + exclude += [d[0] for d in + frappe.db.get_all("Email Account", ["login_id"], {"enable_incoming": 1}, as_list=True) + if d[0]] + + recipients = self.filter_email_list(recipients, exclude) + + return recipients + + def get_cc(self, recipients=None, fetched_from_email_account=False): + """Build a list of email addresses for CC""" + # get a copy of CC list + cc = split_emails(self.cc) if self.reference_doctype and self.reference_name: - recipients += self.get_earlier_participants() - recipients += self.get_commentors() - recipients += self.get_assignees() - recipients += self.get_starrers() + if not cc or fetched_from_email_account: + # if CC is not mentioned from the UI or is a fetched email, add follows to CC + cc.append(self.get_owner_email()) + cc += self.get_assignees() + cc += self.get_starrers() + + if fetched_from_email_account and self.in_reply_to: + # add sender of previous reply + cc.append(frappe.db.get_value("Communication", self.in_reply_to, "sender")) + + if cc: + # this will be used to eventually find email addresses that aren't sent to + self.all_email_addresses.extend(cc) + + # exclude email accounts, unfollows, recipients and unsubscribes + exclude = [d[0] for d in + frappe.db.get_all("Email Account", ["email_id"], {"enable_incoming": 1}, as_list=True)] + exclude += [d[0] for d in + frappe.db.get_all("Email Account", ["login_id"], {"enable_incoming": 1}, as_list=True) + if d[0]] + exclude += [d[0] for d in frappe.db.get_all("User", ["name"], {"thread_notify": 0}, as_list=True)] + exclude += [parseaddr(email)[1] for email in recipients] + + if fetched_from_email_account: + # exclude sender when pulling email + exclude += [parseaddr(self.sender)[1]] - # remove unsubscribed recipients - unsubscribed = [d[0] for d in frappe.db.get_all("User", ["name"], {"thread_notify": 0}, as_list=True)] - email_accounts = [d[0] for d in frappe.db.get_all("Email Account", ["email_id"], {"enable_incoming": 1}, as_list=True)] - sender = parseaddr(self.sender)[1] + if self.reference_doctype and self.reference_name: + exclude += [d[0] for d in frappe.db.get_all("Email Unsubscribe", ["email"], + {"reference_doctype": self.reference_doctype, "reference_name": self.reference_name}, as_list=True)] - filtered = [] - email_addresses = [] - for e in list(set(recipients)): - if (e=="Administrator") or ((e==self.sender) and (e not in original_recipients)) or \ - (e in unsubscribed) or (e in email_accounts): - continue + cc = self.filter_email_list(cc, exclude) + + if getattr(self, "send_me_a_copy", False) and self.sender not in cc: + self.all_email_addresses.append(self.sender) + cc.append(self.sender) - email_id = parseaddr(e)[1] + return cc + + def filter_email_list(self, email_list, exclude): + # temp variables + filtered = [] + email_address_list = [] - if not email_id: + for email in list(set(email_list)): + if email in exclude: continue - if email_id==sender or email_id in unsubscribed or email_id in email_accounts: + email_address = (parseaddr(email)[1] or "").lower() + if not email_address: continue - if except_recipient and (e==self.recipients or email_id==self.recipients): - # while pulling email, don't send email to current recipient + if email_address in exclude: continue # make sure of case-insensitive uniqueness of email address - if email_id.lower() not in email_addresses: + if email_address not in email_address_list: # append the full email i.e. "Human " - filtered.append(e) - email_addresses.append(email_id.lower()) - - if getattr(self, "send_me_a_copy", False): - filtered.append(self.sender) + filtered.append(email) + email_address_list.append(email_address) return filtered def get_starrers(self): """Return list of users who have starred this document.""" - if self.reference_doctype and self.reference_name: - return self.get_parent_doc().get_starred_by() - else: - return [] - - def get_earlier_participants(self): - return frappe.db.sql_list(""" - select distinct sender - from tabCommunication where - reference_doctype=%s and reference_name=%s""", - (self.reference_doctype, self.reference_name)) - - def get_commentors(self): - return frappe.db.sql_list(""" - select distinct comment_by - from tabComment where - comment_doctype=%s and comment_docname=%s and - ifnull(unsubscribed, 0)=0 and comment_by!='Administrator'""", - (self.reference_doctype, self.reference_name)) + return [( get_formatted_email(user) or user ) for user in self.get_parent_doc().get_starred_by()] + + def get_owner_email(self): + owner = self.get_parent_doc().owner + return get_formatted_email(owner) or owner def get_assignees(self): - return [d.owner for d in frappe.db.get_all("ToDo", filters={"reference_type": self.reference_doctype, - "reference_name": self.reference_name, "status": "Open"}, fields=["owner"])] + return [( get_formatted_email(d.owner) or d.owner ) for d in + frappe.db.get_all("ToDo", filters={ + "reference_type": self.reference_doctype, + "reference_name": self.reference_name, + "status": "Open" + }, fields=["owner"]) + ] def get_attach_link(self, print_format): """Returns public link for the attachment via `templates/emails/print_link.html`.""" @@ -256,7 +326,7 @@ def on_doctype_update(): def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent", sender=None, recipients=None, communication_medium="Email", send_email=False, print_html=None, print_format=None, attachments='[]', ignore_doctype_permissions=False, - send_me_a_copy=False): + send_me_a_copy=False, cc=None): """Make a new communication. :param doctype: Reference DocType. @@ -289,6 +359,7 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "content": content, "sender": sender, "recipients": recipients, + "cc": cc or None, "communication_medium": "Email", "sent_or_received": sent_or_received, "reference_doctype": doctype, @@ -300,15 +371,13 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = # if not committed, delayed task doesn't find the communication frappe.db.commit() - recipients = None if send_email: comm.send_me_a_copy = send_me_a_copy - recipients = comm.get_recipients() - comm.send(print_html, print_format, attachments, send_me_a_copy=send_me_a_copy, recipients=recipients) + comm.send(print_html, print_format, attachments, send_me_a_copy=send_me_a_copy) return { "name": comm.name, - "recipients": ", ".join(recipients) if recipients else None + "emails_not_sent_to": ", ".join(comm.emails_not_sent_to) if hasattr(comm, "emails_not_sent_to") else None } @frappe.whitelist() diff --git a/frappe/email/bulk.py b/frappe/email/bulk.py index 3d958fb51f..38aecc35f2 100644 --- a/frappe/email/bulk.py +++ b/frappe/email/bulk.py @@ -10,13 +10,14 @@ from frappe.email.smtp import SMTPServer, get_outgoing_email_account from frappe.email.email_body import get_email, get_formatted_html from frappe.utils.verified_command import get_signed_params, verify_request from html2text import html2text -from frappe.utils import get_url, nowdate, encode, now_datetime, add_days +from frappe.utils import get_url, nowdate, encode, now_datetime, add_days, split_emails class BulkLimitCrossedError(frappe.ValidationError): pass def send(recipients=None, sender=None, subject=None, message=None, reference_doctype=None, reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, - attachments=None, reply_to=None, cc=(), message_id=None, send_after=None): + attachments=None, reply_to=None, cc=(), message_id=None, send_after=None, + expose_recipients=False): """Add email to sending queue (Bulk Email) :param recipients: List of recipients. @@ -39,7 +40,7 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc return if isinstance(recipients, basestring): - recipients = recipients.split(",") + recipients = split_emails(recipients) if isinstance(send_after, int): send_after = add_days(nowdate(), send_after) @@ -66,23 +67,30 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc else: unsubscribed = [] - for email in filter(None, list(set(recipients))): - if email not in unsubscribed: - email_content = formatted - email_text_context = text_content + recipients = [r for r in list(set(recipients)) if r and r not in unsubscribed] - if reference_doctype: - unsubscribe_url = get_unsubcribed_url(reference_doctype, reference_name, email, - unsubscribe_method, unsubscribe_params) + for email in recipients: + email_content = formatted + email_text_context = text_content - # add to queue - email_content = add_unsubscribe_link(email_content, email, reference_doctype, - reference_name, unsubscribe_url, unsubscribe_message) + if reference_doctype: + unsubscribe_link = get_unsubscribe_link( + reference_doctype=reference_doctype, + reference_name=reference_name, + email=email, + recipients=recipients, + expose_recipients=expose_recipients, + unsubscribe_method=unsubscribe_method, + unsubscribe_params=unsubscribe_params, + unsubscribe_message=unsubscribe_message + ) - email_text_context += "\n" + _("This email was sent to {0}. To unsubscribe click on this link: {1}").format(email, unsubscribe_url) + email_content = email_content.replace("", unsubscribe_link.html) + email_text_context += unsubscribe_link.text - add(email, sender, subject, email_content, email_text_context, reference_doctype, - reference_name, attachments, reply_to, cc, message_id, send_after) + # add to queue + add(email, sender, subject, email_content, email_text_context, reference_doctype, + reference_name, attachments, reply_to, cc, message_id, send_after) def add(email, sender, subject, formatted, text_content=None, reference_doctype=None, reference_name=None, attachments=None, reply_to=None, @@ -129,18 +137,41 @@ def check_bulk_limit(recipients): throw(_("Email limit {0} crossed").format(monthly_bulk_mail_limit), BulkLimitCrossedError) -def add_unsubscribe_link(message, email, reference_doctype, reference_name, unsubscribe_url, unsubscribe_message): - unsubscribe_link = """
- {email}. {unsubscribe_message}. - -
""".format(unsubscribe_url = unsubscribe_url, - email= _("This email was sent to {0}").format(email), - unsubscribe_message = unsubscribe_message or _("Unsubscribe from this list")) - - message = message.replace("", unsubscribe_link) - - return message +def get_unsubscribe_link(reference_doctype, reference_name, + email, recipients, expose_recipients, unsubscribe_method, unsubscribe_params, unsubscribe_message): + + unsubscribe_email = recipients if expose_recipients else [email] + unsubscribe_email = _("This email was sent to {0}").format(", ".join(unsubscribe_email)) + + if not unsubscribe_message: + unsubscribe_message = _("Unsubscribe from this list") + + unsubscribe_url = get_unsubcribed_url(reference_doctype, reference_name, email, + unsubscribe_method, unsubscribe_params) + + html = """
+ {email} +

+ {unsubscribe_message} + +

+
""".format( + unsubscribe_url = unsubscribe_url, + email=unsubscribe_email, + unsubscribe_message=unsubscribe_message + ) + + text = "\n{email}\n\n{unsubscribe_message}: {unsubscribe_url}".format( + email=unsubscribe_email, + unsubscribe_message=unsubscribe_message, + unsubscribe_url=unsubscribe_url + ) + + return frappe._dict({ + "html": html, + "text": text + }) def get_unsubcribed_url(reference_doctype, reference_name, email, unsubscribe_method, unsubscribe_params): params = {"email": email.encode("utf-8"), diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 57b00c4fb3..33f1ee0492 100644 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -137,7 +137,7 @@ class EmailAccount(Document): else: frappe.db.commit() - communication.notify(attachments=communication._attachments, except_recipient=True) + communication.notify(attachments=communication._attachments, fetched_from_email_account=True) if exceptions: raise Exception, frappe.as_json(exceptions) @@ -158,6 +158,7 @@ class EmailAccount(Document): "sender_full_name": email.from_real_name, "sender": email.from_email, "recipients": email.mail.get("To"), + "cc": email.mail.get("CC"), "email_account": self.name, "communication_medium": "Email" }) @@ -208,6 +209,9 @@ class EmailAccount(Document): if frappe.db.exists("Communication", in_reply_to): parent = frappe.get_doc("Communication", in_reply_to) + # set in_reply_to of current communication + communication.in_reply_to = in_reply_to + if parent.reference_name: parent = frappe.get_doc(parent.reference_doctype, parent.reference_name) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 48cfdd87c3..41ce986064 100644 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe.utils.pdf import get_pdf from frappe.email.smtp import get_outgoing_email_account -from frappe.utils import get_url, scrub_urls, strip, expand_relative_urls, cint +from frappe.utils import get_url, scrub_urls, strip, expand_relative_urls, cint, split_emails import email.utils from markdown2 import markdown @@ -42,7 +42,7 @@ class EMail: if isinstance(recipients, basestring): recipients = recipients.replace(';', ',').replace('\n', '') - recipients = recipients.split(',') + recipients = split_emails(recipients) # remove null recipients = filter(None, (strip(r) for r in recipients)) @@ -238,18 +238,18 @@ def get_footer(email_account, footer=None): footer = footer or "" if email_account and email_account.footer: - footer += email_account.footer + footer += '
{0}
'.format(email_account.footer) footer += "" company_address = frappe.db.get_default("email_footer_address") if company_address: - footer += '
{0}
'\ + footer += '
{0}
'\ .format(company_address.replace("\n", "
")) if not cint(frappe.db.get_default("disable_standard_email_footer")): for default_mail_footer in frappe.get_hooks("default_mail_footer"): - footer += default_mail_footer + footer += '
{0}
'.format(default_mail_footer) return footer diff --git a/frappe/public/js/frappe/misc/user.js b/frappe/public/js/frappe/misc/user.js index a4ba377e0c..68813d2f09 100644 --- a/frappe/public/js/frappe/misc/user.js +++ b/frappe/public/js/frappe/misc/user.js @@ -193,6 +193,29 @@ $.extend(frappe.user, { is_report_manager: function() { return frappe.user.has_role(['Administrator', 'System Manager', 'Report Manager']); }, + + get_formatted_email: function(email) { + var fullname = frappe.user.full_name(email); + + if (!fullname) { + return email; + } else { + // to quote or to not + var quote = ''; + + // only if these special characters are found + // why? To make the output same as that in python! + if (fullname.search(/[\[\]\\()<>@,:;".]/) !== -1) { + quote = '"'; + } + + return repl('%(quote)s%(fullname)s%(quote)s <%(email)s>', { + fullname: fullname, + email: email, + quote: quote + }); + } + } }); frappe.session_alive = true; diff --git a/frappe/public/js/frappe/misc/utils.js b/frappe/public/js/frappe/misc/utils.js index e57339350e..db9be6ff9f 100644 --- a/frappe/public/js/frappe/misc/utils.js +++ b/frappe/public/js/frappe/misc/utils.js @@ -43,6 +43,9 @@ frappe.utils = { }); return out.join(newline); }, + escape_html: function(txt) { + return $("
").text(txt || "").html(); + }, is_url: function(txt) { return txt.toLowerCase().substr(0,7)=='http://' || txt.toLowerCase().substr(0,8)=='https://' diff --git a/frappe/public/js/frappe/ui/star.js b/frappe/public/js/frappe/ui/star.js index 01a06b6a4a..c07516bf70 100644 --- a/frappe/public/js/frappe/ui/star.js +++ b/frappe/public/js/frappe/ui/star.js @@ -2,12 +2,17 @@ // MIT License. See license.txt frappe.ui.is_starred = function(doc) { + var starred = frappe.ui.get_starred_by(doc); + return starred.indexOf(user)===-1 ? false : true; +} + +frappe.ui.get_starred_by = function(doc) { var starred = doc._starred_by; if(starred) { starred = JSON.parse(starred); - return starred.indexOf(user)===-1 ? false : true; } - return false; + + return starred || []; } frappe.ui.toggle_star = function($btn, doctype, name) { diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 6d0246456b..31eca633c4 100644 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -14,41 +14,7 @@ frappe.views.CommunicationComposer = Class.extend({ this.dialog = new frappe.ui.Dialog({ title: __("Add Reply") + ": " + (this.subject || ""), no_submit_on_enter: true, - fields: [ - {label:__("To"), fieldtype:"Data", reqd: 1, fieldname:"recipients"}, - - {fieldtype: "Section Break"}, - {fieldtype: "Column Break"}, - {label:__("Subject"), fieldtype:"Data", reqd: 1, - fieldname:"subject"}, - {fieldtype: "Column Break"}, - {label:__("Standard Reply"), fieldtype:"Link", options:"Standard Reply", - fieldname:"standard_reply"}, - - {fieldtype: "Section Break"}, - {label:__("Message"), fieldtype:"Text Editor", reqd: 1, - fieldname:"content"}, - - {fieldtype: "Section Break"}, - {fieldtype: "Column Break"}, - {label:__("Send As Email"), fieldtype:"Check", - fieldname:"send_email"}, - {label:__("Send me a copy"), fieldtype:"Check", - fieldname:"send_me_a_copy"}, - {label:__("Communication Medium"), fieldtype:"Select", - options: ["Phone", "Chat", "Email", "SMS", "Visit", "Other"], - fieldname:"communication_medium"}, - {label:__("Sent or Received"), fieldtype:"Select", - options: ["Received", "Sent"], - fieldname:"sent_or_received"}, - {label:__("Attach Document Print"), fieldtype:"Check", - fieldname:"attach_document_print"}, - {label:__("Select Print Format"), fieldtype:"Select", - fieldname:"select_print_format"}, - {fieldtype: "Column Break"}, - {label:__("Select Attachments"), fieldtype:"HTML", - fieldname:"select_attachments"} - ], + fields: this.get_fields(), primary_action_label: "Send", primary_action: function() { me.send_action(); @@ -79,6 +45,100 @@ frappe.views.CommunicationComposer = Class.extend({ this.dialog.show(); }, + + get_fields: function() { + var cc_fields = this.get_cc_fields(); + + var fields_before_cc = [ + {fieldtype: "Section Break"}, + {label:__("To"), fieldtype:"Data", reqd: 1, fieldname:"recipients"}, + {fieldtype: "Section Break", collapsible: 1, label: "CC & Standard Reply"}, + {label:__("CC"), fieldtype:"Data", fieldname:"cc"}, + ]; + + var fields_after_cc = [ + {label:__("Standard Reply"), fieldtype:"Link", options:"Standard Reply", + fieldname:"standard_reply"}, + {fieldtype: "Section Break"}, + {label:__("Subject"), fieldtype:"Data", reqd: 1, + fieldname:"subject"}, + {fieldtype: "Section Break"}, + {label:__("Message"), fieldtype:"Text Editor", reqd: 1, + fieldname:"content"}, + {fieldtype: "Section Break"}, + {fieldtype: "Column Break"}, + {label:__("Send As Email"), fieldtype:"Check", + fieldname:"send_email"}, + {label:__("Send me a copy"), fieldtype:"Check", + fieldname:"send_me_a_copy"}, + {label:__("Communication Medium"), fieldtype:"Select", + options: ["Phone", "Chat", "Email", "SMS", "Visit", "Other"], + fieldname:"communication_medium"}, + {label:__("Sent or Received"), fieldtype:"Select", + options: ["Received", "Sent"], + fieldname:"sent_or_received"}, + {label:__("Attach Document Print"), fieldtype:"Check", + fieldname:"attach_document_print"}, + {label:__("Select Print Format"), fieldtype:"Select", + fieldname:"select_print_format"}, + {fieldtype: "Column Break"}, + {label:__("Select Attachments"), fieldtype:"HTML", + fieldname:"select_attachments"} + ]; + + return fields_before_cc.concat(cc_fields).concat(fields_after_cc); + }, + + get_cc_fields: function() { + var cc = [ [this.frm.doc.owner, 1] ]; + + var starred_by = frappe.ui.get_starred_by(this.frm.doc); + if (starred_by) { + for ( var i=0, l=starred_by.length; i