diff --git a/frappe/__init__.py b/frappe/__init__.py index 1bef6b36df..614731ae78 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -269,7 +269,7 @@ def get_request_header(key, default=None): return request.headers.get(key, default) def sendmail(recipients=(), sender="", subject="No Subject", message="No Message", - as_markdown=False, bulk=False, ref_doctype=None, ref_docname=None, + as_markdown=False, bulk=False, reference_doctype=None, reference_name=None, unsubscribe_url=False, attachments=None, content=None, doctype=None, name=None, reply_to=None): """Send email using user's default **Email Account** or global default **Email Account**. @@ -280,8 +280,8 @@ def sendmail(recipients=(), sender="", subject="No Subject", message="No Message :param message: (or `content`) Email Content. :param as_markdown: Convert content markdown to HTML. :param bulk: Send via scheduled email sender **Bulk Email**. Don't send immediately. - :param ref_doctype: (or `doctype`) Append as communication to this DocType. - :param ref_docname: (or `name`) Append as communication to this document name. + :param reference_doctype: (or `doctype`) Append as communication to this DocType. + :param reference_name: (or `name`) Append as communication to this document name. :param unsubscribe_url: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe?email={email}&name={name}` :param attachments: List of attachments. :param reply_to: Reply-To email id. @@ -290,8 +290,8 @@ def sendmail(recipients=(), sender="", subject="No Subject", message="No Message if bulk: import frappe.email.bulk frappe.email.bulk.send(recipients=recipients, sender=sender, - subject=subject, message=content or message, ref_doctype = doctype or ref_doctype, - ref_docname = name or ref_docname, unsubscribe_url=unsubscribe_url, attachments=attachments, + subject=subject, message=content or message, reference_doctype = doctype or reference_doctype, + reference_name = name or reference_name, unsubscribe_url=unsubscribe_url, attachments=attachments, reply_to=reply_to) else: @@ -861,7 +861,7 @@ def add_version(doc): A **Version** is a JSON dump of the current document state.""" get_doc({ "doctype": "Version", - "ref_doctype": doc.doctype, + "reference_doctype": doc.doctype, "docname": doc.name, "doclist_json": as_json(doc.as_dict()) }).insert(ignore_permissions=True) diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 147c61091d..f5d758d4de 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -90,8 +90,8 @@ class Communication(Document): "sender": mail.sender, "recipient": mail.recipients[0], "message": mail.as_string(), - "ref_doctype": self.reference_doctype, - "ref_docname": self.reference_name + "reference_doctype": self.reference_doctype, + "reference_name": self.reference_name }).insert(ignore_permissions=True) def notify(self, mail, except_sender=False): diff --git a/frappe/email/bulk.py b/frappe/email/bulk.py index d728ca0f88..d638442bf1 100644 --- a/frappe/email/bulk.py +++ b/frappe/email/bulk.py @@ -4,75 +4,107 @@ from __future__ import unicode_literals import frappe import HTMLParser -from urllib import quote_plus from frappe import msgprint, throw, _ 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 class BulkLimitCrossedError(frappe.ValidationError): pass -def send(recipients=None, sender=None, doctype='User', email_field='email', - subject='[No Subject]', message='[No Content]', ref_doctype=None, - ref_docname=None, unsubscribe_url=True, attachments=None, reply_to=None): +def send(recipients=None, sender=None, subject=None, message=None, reference_doctype=None, + reference_name=None, unsubscribe_method=None, unsubscribe_params=None, + attachments=None, reply_to=None, footer_message=None): + """Add email to sending queue (Bulk Email) - if not unsubscribe_url: - unsubscribe_url = "/api/method/frappe.email.bulk.unsubscribe?doctype={doctype}&name={name}&email={email}" + :param recipients: List of recipients. + :param sender: Email sender. + :param subject: Email subject. + :param message: Email message. + :param reference_doctype: Reference DocType of caller document. + :param reference_name: Reference name of caller document. + :param unsubscribe_method: URL method for unsubscribe. Default is `/api/method/frappe.email.bulk.unsubscribe`. + :param unsubscribe_params: additional params for unsubscribed links. default are name, doctype, email + :param attachments: Attachments to be sent. + :param reply_to: Reply to be captured here (default inbox)""" - def check_bulk_limit(new_mails): - this_month = frappe.db.sql("""select count(*) from `tabBulk Email` where - month(creation)=month(%s)""" % nowdate())[0][0] - # No limit for own email settings - smtp_server = SMTPServer() - if smtp_server.email_account and not getattr(smtp_server.email_account, - "from_site_config", False) or frappe.flags.in_test: - monthly_bulk_mail_limit = frappe.conf.get('monthly_bulk_mail_limit') or 500 - - if (this_month + len(recipients)) > monthly_bulk_mail_limit: - throw(_("Bulk email limit {0} crossed").format(monthly_bulk_mail_limit), - BulkLimitCrossedError) - - def update_message(formatted, unsubscribe_url, email): - updated = formatted - my_unsubscribe_url = unsubscribe_url.format(email=quote_plus(email), doctype=quote_plus(ref_doctype), - name=quote_plus(ref_docname)) - - unsubscribe_link = """
""".format(base_url = get_url(), - url = my_unsubscribe_url, message = _("Unsubscribe from this list")) - - updated = updated.replace("", unsubscribe_link) - - return updated + if not unsubscribe_method: + unsubscribe_method = "/api/method/frappe.email.bulk.unsubscribe" if not recipients: - recipients = [] + return if not sender or sender == "Administrator": email_account = get_outgoing_email_account() sender = email_account.get("sender") or email_account.email_id - check_bulk_limit(len(recipients)) + check_bulk_limit(recipients) formatted = get_formatted_html(subject, message) + + try: + text_content = html2text(formatted) + except HTMLParser.HTMLParseError: + text_content = "See html attachment" + unsubscribed = [d.email for d in frappe.db.get_all("Email Unsubscribe", "email", - {"reference_doctype": ref_doctype, "reference_name": ref_docname})] + {"reference_doctype": reference_doctype, "reference_name": reference_name})] + + for email in filter(None, list(set(recipients))): + if email not in unsubscribed: + unsubscribe_url = get_unsubcribed_url(reference_doctype, reference_name, email, + unsubscribe_method, unsubscribe_params) - for r in filter(None, list(set(recipients))): - if r not in unsubscribed: # add to queue - updated = update_message(formatted, unsubscribe_url) - try: - text_content = html2text(updated) - except HTMLParser.HTMLParseError: - text_content = "[See html attachment]" + updated = add_unsubscribe_link(formatted, email, reference_doctype, reference_name, + unsubscribe_url, footer_message) + + text_content += "\n" + _("Unsubscribe link: {0}").format(unsubscribe_url) + + add(email, sender, subject, updated, text_content, reference_doctype, reference_name, attachments, reply_to) - add(r, sender, subject, updated, text_content, ref_doctype, ref_docname, attachments, reply_to) +def check_bulk_limit(recipients): + this_month = frappe.db.sql("""select count(*) from `tabBulk Email` where + month(creation)=month(%s)""" % nowdate())[0][0] + + # No limit for own email settings + smtp_server = SMTPServer() + if smtp_server.email_account and not getattr(smtp_server.email_account, + "from_site_config", False) or frappe.flags.in_test: + monthly_bulk_mail_limit = frappe.conf.get('monthly_bulk_mail_limit') or 500 + + if (this_month + len(recipients)) > monthly_bulk_mail_limit: + throw(_("Bulk email limit {0} crossed").format(monthly_bulk_mail_limit), + BulkLimitCrossedError) + +def add_unsubscribe_link(message, email, reference_doctype, reference_name, unsubscribe_url, footer_message): + unsubscribe_link = """" \ - + _("{0} has been successfully unsubscribed").fomrat(email) + "
" - - frappe.response['type'] = 'page' - frappe.response['page_name'] = 'message.html' + frappe.respond_as_web_page(_("Unsubscribed"), _("{0} has been successfully unsubscribed").format(email)) def flush(from_test=False): """flush email queue, every time: called from scheduler""" diff --git a/frappe/email/doctype/bulk_email/bulk_email.json b/frappe/email/doctype/bulk_email/bulk_email.json index f4811bea44..4766d52073 100644 --- a/frappe/email/doctype/bulk_email/bulk_email.json +++ b/frappe/email/doctype/bulk_email/bulk_email.json @@ -44,7 +44,7 @@ "permlevel": 0 }, { - "fieldname": "ref_doctype", + "fieldname": "reference_doctype", "fieldtype": "Link", "label": "Reference DocType", "options": "DocType", @@ -53,7 +53,7 @@ "reqd": 0 }, { - "fieldname": "ref_docname", + "fieldname": "reference_name", "fieldtype": "Data", "label": "Reference DocName", "permlevel": 0, @@ -64,7 +64,7 @@ "icon": "icon-envelope", "idx": 1, "in_create": 1, - "modified": "2015-01-23 04:32:39.175147", + "modified": "2015-03-19 05:36:16.813340", "modified_by": "Administrator", "module": "Email", "name": "Bulk Email", diff --git a/frappe/email/doctype/bulk_email/test_bulk_email.py b/frappe/email/doctype/bulk_email/test_bulk_email.py new file mode 100644 index 0000000000..89bc1034d0 --- /dev/null +++ b/frappe/email/doctype/bulk_email/test_bulk_email.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest + +# test_records = frappe.get_test_records('Bulk Email') + +class TestBulkEmail(unittest.TestCase): + pass diff --git a/frappe/email/doctype/email_alert/email_alert.py b/frappe/email/doctype/email_alert/email_alert.py index 7d0b253dfb..d4cea4a3ab 100644 --- a/frappe/email/doctype/email_alert/email_alert.py +++ b/frappe/email/doctype/email_alert/email_alert.py @@ -91,5 +91,5 @@ def evaluate_alert(doc, alert, event): frappe.sendmail(recipients=recipients, subject=alert.subject, message= frappe.render_template(alert.message, {"doc": doc, "alert":alert}), - bulk=True, ref_doctype = doc.doctype, ref_docname = doc.name, + bulk=True, reference_doctype = doc.doctype, reference_name = doc.name, attachments = [frappe.attach_print(doc.doctype, doc.name)] if alert.attach_print else None) diff --git a/frappe/email/doctype/email_alert/test_email_alert.py b/frappe/email/doctype/email_alert/test_email_alert.py index 33ea0e4a03..b4e438b84f 100644 --- a/frappe/email/doctype/email_alert/test_email_alert.py +++ b/frappe/email/doctype/email_alert/test_email_alert.py @@ -20,16 +20,16 @@ class TestEmailAlert(unittest.TestCase): comment.comment = "test" comment.insert(ignore_permissions=True) - self.assertTrue(frappe.db.get_value("Bulk Email", {"ref_doctype": "Comment", - "ref_docname": comment.name, "status":"Not Sent"})) + self.assertTrue(frappe.db.get_value("Bulk Email", {"reference_doctype": "Comment", + "reference_name": comment.name, "status":"Not Sent"})) frappe.db.sql("""delete from `tabBulk Email`""") comment.description = "test" comment.save() - self.assertTrue(frappe.db.get_value("Bulk Email", {"ref_doctype": "Comment", - "ref_docname": comment.name, "status":"Not Sent"})) + self.assertTrue(frappe.db.get_value("Bulk Email", {"reference_doctype": "Comment", + "reference_name": comment.name, "status":"Not Sent"})) def test_condition(self): event = frappe.new_doc("Event") @@ -38,14 +38,14 @@ class TestEmailAlert(unittest.TestCase): event.starts_on = "2014-06-06 12:00:00" event.insert() - self.assertFalse(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertFalse(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) event.event_type = "Public" event.save() - self.assertTrue(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertTrue(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) def test_value_changed(self): event = frappe.new_doc("Event") @@ -54,20 +54,20 @@ class TestEmailAlert(unittest.TestCase): event.starts_on = "2014-06-06 12:00:00" event.insert() - self.assertFalse(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertFalse(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) event.subject = "test 1" event.save() - self.assertFalse(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertFalse(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) event.description = "test" event.save() - self.assertTrue(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertTrue(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) def test_date_changed(self): event = frappe.new_doc("Event") @@ -76,23 +76,23 @@ class TestEmailAlert(unittest.TestCase): event.starts_on = "2014-01-01 12:00:00" event.insert() - self.assertFalse(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertFalse(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) frappe.utils.scheduler.trigger(frappe.local.site, "daily", now=True) # not today, so no alert - self.assertFalse(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertFalse(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) event.starts_on = frappe.utils.add_days(frappe.utils.nowdate(), 2) + " 12:00:00" event.save() - self.assertFalse(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertFalse(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) frappe.utils.scheduler.trigger(frappe.local.site, "daily", now=True) # today so show alert - self.assertTrue(frappe.db.get_value("Bulk Email", {"ref_doctype": "Event", - "ref_docname": event.name, "status":"Not Sent"})) + self.assertTrue(frappe.db.get_value("Bulk Email", {"reference_doctype": "Event", + "reference_name": event.name, "status":"Not Sent"})) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index ad78acc9a3..69cc36ced9 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -241,7 +241,7 @@ class BaseDocument(object): values = ", ".join(["%s"] * len(columns)) ), d.values()) except Exception, e: - if e.args[0]==1062 and "PRIMARY" in e.message: + if e.args[0]==1062 and "PRIMARY" in e.args[1]: if self.meta.autoname=="hash": self.name = None self.db_insert() diff --git a/frappe/model/create_new.py b/frappe/model/create_new.py index 98da848d5a..8090fe5031 100644 --- a/frappe/model/create_new.py +++ b/frappe/model/create_new.py @@ -74,9 +74,9 @@ def get_default_value(df, defaults, user_permissions, parent_doc): # default value based on another document ref_doctype = df.default[1:] ref_fieldname = ref_doctype.lower().replace(" ", "_") - ref_docname = parent_doc.get(ref_fieldname) if parent_doc else frappe.db.get_default(ref_fieldname) + reference_name = parent_doc.get(ref_fieldname) if parent_doc else frappe.db.get_default(ref_fieldname) - default_value = frappe.db.get_value(ref_doctype, ref_docname, df.fieldname) + default_value = frappe.db.get_value(ref_doctype, reference_name, df.fieldname) is_allowed_default_value = (not user_permissions_exist or (default_value in user_permissions.get(df.options, []))) diff --git a/frappe/patches.txt b/frappe/patches.txt index 7da4e55d19..fdcfa32f8b 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -65,3 +65,4 @@ frappe.patches.v5_0.fix_feed frappe.patches.v5_0.update_shared frappe.patches.v5_0.bookmarks_to_stars frappe.patches.v5_0.style_settings_to_website_theme +frappe.patches.v5_0.rename_ref_type_fieldnames diff --git a/frappe/patches/v5_0/rename_ref_type_fieldnames.py b/frappe/patches/v5_0/rename_ref_type_fieldnames.py new file mode 100644 index 0000000000..b8a049c94f --- /dev/null +++ b/frappe/patches/v5_0/rename_ref_type_fieldnames.py @@ -0,0 +1,10 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.db.sql("alter table `tabBulk Email` change `ref_docname` `reference_name` varchar(255)") + frappe.db.sql("alter table `tabBulk Email` change `reference_doctype` `reference_doctype` varchar(255)") + frappe.reload_doctype("Bulk Email") diff --git a/frappe/patches/v5_0/v4_to_v5.py b/frappe/patches/v5_0/v4_to_v5.py index f23c7e43b0..16ce4f8f39 100644 --- a/frappe/patches/v5_0/v4_to_v5.py +++ b/frappe/patches/v5_0/v4_to_v5.py @@ -1,3 +1,7 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals import frappe def execute(): diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py index eb05e54489..e6c499672a 100644 --- a/frappe/templates/includes/comments/comments.py +++ b/frappe/templates/includes/comments/comments.py @@ -53,11 +53,9 @@ def add_comment(args=None): from frappe.email.bulk import send send(recipients=recipients, - doctype='Comment', - email_field='comment_by', subject = _("New comment on {0} {1}").format(comment.comment_doctype, comment.comment_docname), message = message, - ref_doctype=comment.comment_doctype, ref_docname=comment.comment_docname) + reference_doctype=comment.comment_doctype, reference_name=comment.comment_docname) template = frappe.get_template("templates/includes/comments/comment.html") diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index 769246ab59..6ff09390fb 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -11,7 +11,7 @@ make_test_records("Email Account") class TestEmail(unittest.TestCase): def setUp(self): - frappe.db.sql("""update tabUser set unsubscribed=0""") + frappe.db.sql("""delete from `tabEmail Unsubscribe`""") frappe.db.sql("""delete from `tabBulk Email`""") def test_send(self): @@ -22,7 +22,7 @@ class TestEmail(unittest.TestCase): from frappe.email.bulk import send send(recipients = ['test@example.com', 'test1@example.com'], sender="admin@example.com", - doctype='User', email_field='email', + reference_doctype='User', reference_name='Administrator', subject='Testing Bulk', message='This is a bulk mail!') bulk = frappe.db.sql("""select * from `tabBulk Email` where status='Not Sent'""", as_dict=1) @@ -42,17 +42,14 @@ class TestEmail(unittest.TestCase): def test_unsubscribe(self): from frappe.email.bulk import unsubscribe, send - frappe.local.form_dict = frappe._dict({ - 'email':'test@example.com', - 'type':'User', - 'email_field':'email', - "from_test": True - }) - unsubscribe() + unsubscribe(doctype="User", name="Administrator", email="test@example.com") + + self.assertTrue(frappe.db.get_value("Email Unsubscribe", + {"reference_doctype": "User", "reference_name": "Administrator", "email": "test@example.com"})) send(recipients = ['test@example.com', 'test1@example.com'], sender="admin@example.com", - doctype='User', email_field='email', + reference_doctype='User', reference_name= "Administrator", subject='Testing Bulk', message='This is a bulk mail!') bulk = frappe.db.sql("""select * from `tabBulk Email` where status='Not Sent'""", @@ -67,7 +64,7 @@ class TestEmail(unittest.TestCase): self.assertRaises(BulkLimitCrossedError, send, recipients=['test@example.com']*1000, sender="admin@example.com", - doctype='User', email_field='email', + reference_doctype = "User", reference_name="Administrator", subject='Testing Bulk', message='This is a bulk mail!') diff --git a/frappe/utils/verified_command.py b/frappe/utils/verified_command.py index bc1966d4b8..dad7a2216e 100644 --- a/frappe/utils/verified_command.py +++ b/frappe/utils/verified_command.py @@ -4,25 +4,40 @@ from __future__ import unicode_literals import hmac import urllib +from frappe import _ import frappe import frappe.utils def get_signed_params(params): + """Sign a url by appending `&_signature=xxxxx` to given params (string or dict). + + :param params: String or dict of parameters.""" if not isinstance(params, basestring): params = urllib.urlencode(params) + signature = hmac.new(params) signature.update(get_secret()) return params + "&_signature=" + signature.hexdigest() def get_secret(): - return frappe.local.conf.get("secret") or frappe.db.get_value("User", "Administrator", "creation") + return frappe.local.conf.get("secret") or str(frappe.db.get_value("User", "Administrator", "creation")) def verify_request(): - params, signature = frappe.request.query_string.split("&_signature=") - given_signature = hmac.new(params) + """Verify if the incoming signed request if it is correct.""" + query_string = frappe.request.query_string if hasattr(frappe.request, "query_string") \ + else frappe.local.flags.signed_query_string + + params, signature = query_string.split("&_signature=") + + given_signature = hmac.new(params.encode("utf-8")) given_signature.update(get_secret()) - return signature == given_signature + valid = signature == given_signature.hexdigest() + + if not valid: + frappe.respond_as_web_page(_("Invalid Link"), + _("This link is invalid or expired. Please make sure you have pasted correctly.")) + return valid def get_url(cmd, params, nonce=None, secret=None): if not nonce: