# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt from __future__ import unicode_literals from six.moves import range import frappe from six.moves import html_parser as HTMLParser import smtplib, quopri, json 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, add_attachment 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, split_emails, cstr, cint from frappe.utils.file_manager import get_file from rq.timeouts import JobTimeoutException from frappe.utils.scheduler import log from six import text_type, string_types class EmailLimitCrossedError(frappe.ValidationError): pass def send(recipients=None, sender=None, subject=None, message=None, text_content=None, reference_doctype=None, reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, attachments=None, reply_to=None, cc=[], bcc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=None, send_priority=1, communication=None, now=False, read_receipt=None, queue_separately=False, is_notification=False, add_unsubscribe_link=1, inline_images=None, header=None): """Add email to sending queue (Email Queue) :param recipients: List of recipients. :param sender: Email sender. :param subject: Email subject. :param message: Email message. :param text_content: Text version of email message. :param reference_doctype: Reference DocType of caller document. :param reference_name: Reference name of caller document. :param send_priority: Priority for Email Queue, default 1. :param unsubscribe_method: URL method for unsubscribe. Default is `/api/method/frappe.email.queue.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) :param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To. :param send_after: Send this email after the given datetime. If value is in integer, then `send_after` will be the automatically set to no of days from current date. :param communication: Communication link to be set in Email Queue record :param now: Send immediately (don't send in the background) :param queue_separately: Queue each email separately :param is_notification: Marks email as notification so will not trigger notifications from system :param add_unsubscribe_link: Send unsubscribe link in the footer of the Email, default 1. :param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id :param header: Append header in email (boolean) """ if not unsubscribe_method: unsubscribe_method = "/api/method/frappe.email.queue.unsubscribe" if not recipients and not cc: return if isinstance(recipients, string_types): recipients = split_emails(recipients) if isinstance(cc, string_types): cc = split_emails(cc) if isinstance(bcc, string_types): bcc = split_emails(bcc) if isinstance(send_after, int): send_after = add_days(nowdate(), send_after) email_account = get_outgoing_email_account(True, append_to=reference_doctype, sender=sender) if not sender or sender == "Administrator": sender = email_account.default_sender check_email_limit(recipients) if not text_content: try: text_content = html2text(message) except HTMLParser.HTMLParseError: text_content = "See html attachment" if reference_doctype and reference_name: unsubscribed = [d.email for d in frappe.db.get_all("Email Unsubscribe", "email", {"reference_doctype": reference_doctype, "reference_name": reference_name})] unsubscribed += [d.email for d in frappe.db.get_all("Email Unsubscribe", "email", {"global_unsubscribe": 1})] else: unsubscribed = [] recipients = [r for r in list(set(recipients)) if r and r not in unsubscribed] email_text_context = text_content should_append_unsubscribe = (add_unsubscribe_link and reference_doctype and (unsubscribe_message or reference_doctype=="Newsletter") and add_unsubscribe_link==1) unsubscribe_link = None if should_append_unsubscribe: unsubscribe_link = get_unsubscribe_message(unsubscribe_message, expose_recipients) email_text_context += unsubscribe_link.text email_content = get_formatted_html(subject, message, email_account=email_account, header=header, unsubscribe_link=unsubscribe_link) # add to queue add(recipients, sender, subject, formatted=email_content, text_content=email_text_context, reference_doctype=reference_doctype, reference_name=reference_name, attachments=attachments, reply_to=reply_to, cc=cc, bcc=bcc, message_id=message_id, in_reply_to=in_reply_to, send_after=send_after, send_priority=send_priority, email_account=email_account, communication=communication, add_unsubscribe_link=add_unsubscribe_link, unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, expose_recipients=expose_recipients, read_receipt=read_receipt, queue_separately=queue_separately, is_notification = is_notification, inline_images = inline_images, header=header, now=now) def add(recipients, sender, subject, **kwargs): """Add to Email Queue""" if kwargs.get('queue_separately') or len(recipients) > 20: email_queue = None for r in recipients: if not email_queue: email_queue = get_email_queue([r], sender, subject, **kwargs) if kwargs.get('now'): email_queue(email_queue.name, now=True) else: duplicate = email_queue.get_duplicate([r]) duplicate.insert(ignore_permissions=True) if kwargs.get('now'): send_one(duplicate.name, now=True) frappe.db.commit() else: email_queue = get_email_queue(recipients, sender, subject, **kwargs) if kwargs.get('now'): send_one(email_queue.name, now=True) def get_email_queue(recipients, sender, subject, **kwargs): '''Make Email Queue object''' e = frappe.new_doc('Email Queue') e.priority = kwargs.get('send_priority') attachments = kwargs.get('attachments') if attachments: # store attachments with fid, to be attached on-demand later _attachments = [] for att in attachments: if att.get('fid'): _attachments.append(att) e.attachments = json.dumps(_attachments) try: mail = get_email(recipients, sender=sender, subject=subject, formatted=kwargs.get('formatted'), text_content=kwargs.get('text_content'), attachments=kwargs.get('attachments'), reply_to=kwargs.get('reply_to'), cc=kwargs.get('cc'), bcc=kwargs.get('bcc'), email_account=kwargs.get('email_account'), expose_recipients=kwargs.get('expose_recipients'), inline_images=kwargs.get('inline_images'), header=kwargs.get('header')) mail.set_message_id(kwargs.get('message_id'),kwargs.get('is_notification')) if kwargs.get('read_receipt'): mail.msg_root["Disposition-Notification-To"] = sender if kwargs.get('in_reply_to'): mail.set_in_reply_to(kwargs.get('in_reply_to')) e.message_id = mail.msg_root["Message-Id"].strip(" <>") e.message = cstr(mail.as_string()) e.sender = mail.sender except frappe.InvalidEmailAddressError: # bad Email Address - don't add to queue frappe.log_error('Invalid Email ID Sender: {0}, Recipients: {1}'.format(mail.sender, ', '.join(mail.recipients)), 'Email Not Sent') e.set_recipients(recipients + kwargs.get('cc', []) + kwargs.get('bcc', [])) e.reference_doctype = kwargs.get('reference_doctype') e.reference_name = kwargs.get('reference_name') e.add_unsubscribe_link = kwargs.get("add_unsubscribe_link") e.unsubscribe_method = kwargs.get('unsubscribe_method') e.unsubscribe_params = kwargs.get('unsubscribe_params') e.expose_recipients = kwargs.get('expose_recipients') e.communication = kwargs.get('communication') e.send_after = kwargs.get('send_after') e.show_as_cc = ",".join(kwargs.get('cc', [])) e.show_as_bcc = ",".join(kwargs.get('bcc', [])) e.insert(ignore_permissions=True) return e def check_email_limit(recipients): # if using settings from site_config.json, check email limit # No limit for own email settings smtp_server = SMTPServer() if (smtp_server.email_account and getattr(smtp_server.email_account, "from_site_config", False) or frappe.flags.in_test): monthly_email_limit = frappe.conf.get('limits', {}).get('emails') daily_email_limit = cint(frappe.conf.get('limits', {}).get('daily_emails')) if frappe.flags.in_test: monthly_email_limit = 500 daily_email_limit = 50 if daily_email_limit: # get count of sent mails in last 24 hours today = get_emails_sent_today() if (today + len(recipients)) > daily_email_limit: throw(_("Cannot send this email. You have crossed the sending limit of {0} emails for this day.").format(daily_email_limit), EmailLimitCrossedError) if not monthly_email_limit: return # get count of mails sent this month this_month = get_emails_sent_this_month() if (this_month + len(recipients)) > monthly_email_limit: throw(_("Cannot send this email. You have crossed the sending limit of {0} emails for this month.").format(monthly_email_limit), EmailLimitCrossedError) def get_emails_sent_this_month(): return frappe.db.sql("""select count(name) from `tabEmail Queue` where status='Sent' and MONTH(creation)=MONTH(CURDATE())""")[0][0] def get_emails_sent_today(): return frappe.db.sql("""select count(name) from `tabEmail Queue` where status='Sent' and creation>DATE_SUB(NOW(), INTERVAL 24 HOUR)""")[0][0] def get_unsubscribe_message(unsubscribe_message, expose_recipients): if unsubscribe_message: unsubscribe_html = '''{0}'''.format(unsubscribe_message) else: unsubscribe_link = '''{0}'''.format(_('Unsubscribe')) unsubscribe_html = _("{0} to stop receiving emails of this type").format(unsubscribe_link) html = """
{0}
""".format(unsubscribe_html) if expose_recipients == "footer": text = "\n" else: text = "" text += "\n\n{unsubscribe_message}: \n".format(unsubscribe_message=unsubscribe_message) 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"), "doctype": reference_doctype.encode("utf-8"), "name": reference_name.encode("utf-8")} if unsubscribe_params: params.update(unsubscribe_params) query_string = get_signed_params(params) # for test frappe.local.flags.signed_query_string = query_string return get_url(unsubscribe_method + "?" + get_signed_params(params)) @frappe.whitelist(allow_guest=True) def unsubscribe(doctype, name, email): # unsubsribe from comments and communications if not verify_request(): return try: frappe.get_doc({ "doctype": "Email Unsubscribe", "email": email, "reference_doctype": doctype, "reference_name": name }).insert(ignore_permissions=True) except frappe.DuplicateEntryError: frappe.db.rollback() else: frappe.db.commit() return_unsubscribed_page(email, doctype, name) def return_unsubscribed_page(email, doctype, name): frappe.respond_as_web_page(_("Unsubscribed"), _("{0} has left the conversation in {1} {2}").format(email, _(doctype), name), indicator_color='green') def flush(from_test=False): """flush email queue, every time: called from scheduler""" # additional check cache = frappe.cache() check_email_limit([]) auto_commit = not from_test if frappe.are_emails_muted(): msgprint(_("Emails are muted")) from_test = True smtpserver = SMTPServer() make_cache_queue() for i in range(cache.llen('cache_email_queue')): email = cache.lpop('cache_email_queue') if cint(frappe.defaults.get_defaults().get("hold_queue"))==1: break if email: send_one(email, smtpserver, auto_commit, from_test=from_test) # NOTE: removing commit here because we pass auto_commit # finally: # frappe.db.commit() def make_cache_queue(): '''cache values in queue before sendign''' cache = frappe.cache() emails = frappe.db.sql('''select name from `tabEmail Queue` where (status='Not Sent' or status='Partially Sent') and (send_after is null or send_after < %(now)s) order by priority desc, creation asc limit 500''', { 'now': now_datetime() }) # reset value cache.delete_value('cache_email_queue') for e in emails: cache.rpush('cache_email_queue', e[0]) def send_one(email, smtpserver=None, auto_commit=True, now=False, from_test=False): '''Send Email Queue with given smtpserver''' email = frappe.db.sql('''select name, status, communication, message, sender, reference_doctype, reference_name, unsubscribe_param, unsubscribe_method, expose_recipients, show_as_cc, add_unsubscribe_link, attachments from `tabEmail Queue` where name=%s for update''', email, as_dict=True)[0] recipients_list = frappe.db.sql('''select name, recipient, status from `tabEmail Queue Recipient` where parent=%s''',email.name,as_dict=1) if frappe.are_emails_muted(): frappe.msgprint(_("Emails are muted")) return if cint(frappe.defaults.get_defaults().get("hold_queue"))==1 : return if email.status not in ('Not Sent','Partially Sent') : # rollback to release lock and return frappe.db.rollback() return frappe.db.sql("""update `tabEmail Queue` set status='Sending', modified=%s where name=%s""", (now_datetime(), email.name), auto_commit=auto_commit) if email.communication: frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit) try: if not frappe.flags.in_test: if not smtpserver: smtpserver = SMTPServer() smtpserver.setup_email_account(email.reference_doctype, sender=email.sender) for recipient in recipients_list: if recipient.status != "Not Sent": continue message = prepare_message(email, recipient.recipient, recipients_list) if not frappe.flags.in_test: smtpserver.sess.sendmail(email.sender, recipient.recipient, encode(message)) recipient.status = "Sent" frappe.db.sql("""update `tabEmail Queue Recipient` set status='Sent', modified=%s where name=%s""", (now_datetime(), recipient.name), auto_commit=auto_commit) #if all are sent set status if any("Sent" == s.status for s in recipients_list): frappe.db.sql("""update `tabEmail Queue` set status='Sent', modified=%s where name=%s""", (now_datetime(), email.name), auto_commit=auto_commit) else: frappe.db.sql("""update `tabEmail Queue` set status='Error', error=%s where name=%s""", ("No recipients to send to", email.name), auto_commit=auto_commit) if frappe.flags.in_test: frappe.flags.sent_mail = message return if email.communication: frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit) except (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, smtplib.SMTPHeloError, smtplib.SMTPAuthenticationError, JobTimeoutException): # bad connection/timeout, retry later if any("Sent" == s.status for s in recipients_list): frappe.db.sql("""update `tabEmail Queue` set status='Partially Sent', modified=%s where name=%s""", (now_datetime(), email.name), auto_commit=auto_commit) else: frappe.db.sql("""update `tabEmail Queue` set status='Not Sent', modified=%s where name=%s""", (now_datetime(), email.name), auto_commit=auto_commit) if email.communication: frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit) # no need to attempt further return except Exception as e: frappe.db.rollback() if any("Sent" == s.status for s in recipients_list): frappe.db.sql("""update `tabEmail Queue` set status='Partially Errored', error=%s where name=%s""", (text_type(e), email.name), auto_commit=auto_commit) else: frappe.db.sql("""update `tabEmail Queue` set status='Error', error=%s where name=%s""", (text_type(e), email.name), auto_commit=auto_commit) if email.communication: frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit) if now: print(frappe.get_traceback()) raise e else: # log to Error Log log('frappe.email.queue.flush', text_type(e)) def prepare_message(email, recipient, recipients_list): message = email.message if not message: return "" if email.add_unsubscribe_link and email.reference_doctype: # is missing the check for unsubscribe message but will not add as there will be no unsubscribe url unsubscribe_url = get_unsubcribed_url(email.reference_doctype, email.reference_name, recipient, email.unsubscribe_method, email.unsubscribe_params) message = message.replace("", quopri.encodestring(unsubscribe_url.encode()).decode()) if email.expose_recipients == "header": pass else: if email.expose_recipients == "footer": if isinstance(email.show_as_cc, string_types): email.show_as_cc = email.show_as_cc.split(",") email_sent_to = [r.recipient for r in recipients_list] email_sent_cc = ", ".join([e for e in email_sent_to if e in email.show_as_cc]) email_sent_to = ", ".join([e for e in email_sent_to if e not in email.show_as_cc]) if email_sent_cc: email_sent_message = _("This email was sent to {0} and copied to {1}").format(email_sent_to,email_sent_cc) else: email_sent_message = _("This email was sent to {0}").format(email_sent_to) message = message.replace("", quopri.encodestring(email_sent_message.encode()).decode()) message = message.replace("", recipient) message = (message and message.encode('utf8')) or '' if not email.attachments: return message # On-demand attachments from email.parser import Parser msg_obj = Parser().parsestr(message) attachments = json.loads(email.attachments) for attachment in attachments: if attachment.get('fcontent'): continue fid = attachment.get('fid') if not fid: continue fname, fcontent = get_file(fid) attachment.update({ 'fname': fname, 'fcontent': fcontent, 'parent': msg_obj }) attachment.pop("fid", None) add_attachment(**attachment) return msg_obj.as_string() def clear_outbox(): """Remove low priority older than 31 days in Outbox and expire mails not sent for 7 days. Called daily via scheduler. Note: Used separate query to avoid deadlock """ email_queues = frappe.db.sql_list("""select name from `tabEmail Queue` where priority=0 and datediff(now(), modified) > 31""") if email_queues: frappe.db.sql("""delete from `tabEmail Queue` where name in (%s)""" % ','.join(['%s']*len(email_queues)), tuple(email_queues)) frappe.db.sql("""delete from `tabEmail Queue Recipient` where parent in (%s)""" % ','.join(['%s']*len(email_queues)), tuple(email_queues)) frappe.db.sql(""" update `tabEmail Queue` set status='Expired' where datediff(curdate(), modified) > 7 and status='Not Sent' and (send_after is null or send_after < %(now)s)""", { 'now': now_datetime() })