diff --git a/mail/mail/report/mail_tracker/mail_tracker.py b/mail/mail/report/mail_tracker/mail_tracker.py index 864a37fc..1121502e 100644 --- a/mail/mail/report/mail_tracker/mail_tracker.py +++ b/mail/mail/report/mail_tracker/mail_tracker.py @@ -3,14 +3,13 @@ import frappe from frappe import _ -from typing import Tuple from frappe.query_builder.functions import Date from frappe.query_builder import Order, Criterion from mail.utils.cache import get_user_owned_domains from mail.utils.user import has_role, is_system_manager, get_user_mailboxes -def execute(filters=None) -> Tuple[list, list]: +def execute(filters: dict | None = None) -> tuple: columns = get_columns() data = get_data(filters) summary = get_summary(data) @@ -18,96 +17,7 @@ def execute(filters=None) -> Tuple[list, list]: return columns, data, None, None, summary -def get_data(filters=None) -> list: - filters = filters or {} - - OM = frappe.qb.DocType("Outgoing Mail") - query = ( - frappe.qb.from_(OM) - .select( - OM.name, - OM.creation, - OM.status, - OM.open_count, - OM.agent, - OM.domain_name, - OM.sender, - OM.message_id, - OM.tracking_id, - OM.created_at, - OM.first_opened_at, - OM.last_opened_at, - OM.last_opened_from_ip, - ) - .where((OM.docstatus == 1) & (OM.tracking_id.isnotnull())) - .orderby(OM.creation, OM.created_at, order=Order.desc) - ) - - if ( - not filters.get("name") - and not filters.get("message_id") - and not filters.get("tracking_id") - ): - query = query.where( - (Date(OM.created_at) >= Date(filters.get("from_date"))) - & (Date(OM.created_at) <= Date(filters.get("to_date"))) - ) - - for field in [ - "name", - "status", - "agent", - "domain_name", - "sender", - "message_id", - "tracking_id", - ]: - if filters.get(field): - query = query.where(OM[field] == filters.get(field)) - - user = frappe.session.user - if not is_system_manager(user): - conditions = [] - domains = get_user_owned_domains(user) - mailboxes = get_user_mailboxes(user) - - if has_role(user, "Domain Owner") and domains: - conditions.append(OM.domain_name.isin(domains)) - - if has_role(user, "Mailbox User") and mailboxes: - conditions.append(OM.sender.isin(mailboxes)) - - if not conditions: - return [] - - query = query.where(Criterion.any(conditions)) - - return query.run(as_dict=True) - - -def get_summary(data: dict) -> list[dict]: - total_open_count = 0 - for row in data: - if row["open_count"] > 0: - total_open_count += 1 - - return [ - { - "value": len(data), - "indicator": "green", - "label": "Total Sent", - "datatype": "Int", - }, - { - "value": total_open_count, - "indicator": "blue", - "label": "Total Opened", - "datatype": "Int", - }, - ] - - -def get_columns() -> list: +def get_columns() -> list[dict]: return [ { "label": _("Name"), @@ -191,3 +101,92 @@ def get_columns() -> list: "width": 120, }, ] + + +def get_data(filters: dict | None = None) -> list[list]: + filters = filters or {} + + OM = frappe.qb.DocType("Outgoing Mail") + query = ( + frappe.qb.from_(OM) + .select( + OM.name, + OM.creation, + OM.status, + OM.open_count, + OM.agent, + OM.domain_name, + OM.sender, + OM.message_id, + OM.tracking_id, + OM.created_at, + OM.first_opened_at, + OM.last_opened_at, + OM.last_opened_from_ip, + ) + .where((OM.docstatus == 1) & (OM.tracking_id.isnotnull())) + .orderby(OM.creation, OM.created_at, order=Order.desc) + ) + + if ( + not filters.get("name") + and not filters.get("message_id") + and not filters.get("tracking_id") + ): + query = query.where( + (Date(OM.created_at) >= Date(filters.get("from_date"))) + & (Date(OM.created_at) <= Date(filters.get("to_date"))) + ) + + for field in [ + "name", + "status", + "agent", + "domain_name", + "sender", + "message_id", + "tracking_id", + ]: + if filters.get(field): + query = query.where(OM[field] == filters.get(field)) + + user = frappe.session.user + if not is_system_manager(user): + conditions = [] + domains = get_user_owned_domains(user) + mailboxes = get_user_mailboxes(user) + + if has_role(user, "Domain Owner") and domains: + conditions.append(OM.domain_name.isin(domains)) + + if has_role(user, "Mailbox User") and mailboxes: + conditions.append(OM.sender.isin(mailboxes)) + + if not conditions: + return [] + + query = query.where(Criterion.any(conditions)) + + return query.run(as_dict=True) + + +def get_summary(data: dict) -> list[dict]: + total_open_count = 0 + for row in data: + if row["open_count"] > 0: + total_open_count += 1 + + return [ + { + "label": _("Total Sent"), + "datatype": "Int", + "value": len(data), + "indicator": "green", + }, + { + "label": _("Total Opened"), + "datatype": "Int", + "value": total_open_count, + "indicator": "blue", + }, + ] diff --git a/mail/mail/report/outbound_delay/outbound_delay.py b/mail/mail/report/outbound_delay/outbound_delay.py index 5d39c906..f85bc874 100644 --- a/mail/mail/report/outbound_delay/outbound_delay.py +++ b/mail/mail/report/outbound_delay/outbound_delay.py @@ -4,13 +4,14 @@ import frappe from frappe import _ from typing import Tuple +from frappe.utils import flt from frappe.query_builder import Order, Criterion from mail.utils.cache import get_user_owned_domains from frappe.query_builder.functions import Date, IfNull from mail.utils.user import has_role, is_system_manager, get_user_mailboxes -def execute(filters=None) -> Tuple[list, list]: +def execute(filters: dict | None = None) -> Tuple[list, list]: columns = get_columns() data = get_data(filters) summary = get_summary(data) @@ -18,138 +19,7 @@ def execute(filters=None) -> Tuple[list, list]: return columns, data, None, None, summary -def get_data(filters=None) -> list: - filters = filters or {} - - OM = frappe.qb.DocType("Outgoing Mail") - MR = frappe.qb.DocType("Mail Recipient") - - query = ( - frappe.qb.from_(OM) - .left_join(MR) - .on(OM.name == MR.parent) - .select( - OM.name, - OM.creation, - MR.status, - MR.retries, - OM.message_size, - OM.via_api, - OM.is_newsletter, - OM.submitted_after.as_("submission_delay"), - (OM.transfer_started_after + OM.transfer_completed_after).as_("transfer_delay"), - MR.action_after.as_("action_delay"), - ( - OM.submitted_after - + OM.transfer_started_after - + OM.transfer_completed_after - + MR.action_after - ).as_("total_delay"), - OM.agent, - OM.domain_name, - OM.ip_address, - OM.sender, - MR.email.as_("recipient"), - OM.message_id, - ) - .where((OM.docstatus == 1) & (IfNull(MR.status, "") != "")) - .orderby(OM.creation, OM.created_at, order=Order.desc) - .orderby(MR.idx, order=Order.asc) - ) - - if not filters.get("name") and not filters.get("message_id"): - query = query.where( - (Date(OM.created_at) >= Date(filters.get("from_date"))) - & (Date(OM.created_at) <= Date(filters.get("to_date"))) - ) - - if not filters.get("include_newsletter"): - query = query.where(OM.is_newsletter == 0) - - for field in [ - "name", - "agent", - "domain_name", - "ip_address", - "sender", - "message_id", - ]: - if filters.get(field): - query = query.where(OM[field] == filters.get(field)) - - for field in ["status", "email"]: - if filters.get(field): - query = query.where(MR[field] == filters.get(field)) - - user = frappe.session.user - if not is_system_manager(user): - conditions = [] - domains = get_user_owned_domains(user) - mailboxes = get_user_mailboxes(user) - - if has_role(user, "Domain Owner") and domains: - conditions.append(OM.domain_name.isin(domains)) - - if has_role(user, "Mailbox User") and mailboxes: - conditions.append(OM.sender.isin(mailboxes)) - - if not conditions: - return [] - - query = query.where(Criterion.any(conditions)) - - return query.run(as_dict=True) - - -def get_summary(data: list) -> list[dict]: - status_count = {} - total_message_size = 0 - total_transfer_delay = 0 - - for row in data: - status = row["status"] - if status in ["Sent", "Deferred", "Bounced"]: - status_count.setdefault(status, 0) - status_count[status] += 1 - - total_message_size += row["message_size"] - total_transfer_delay += row["transfer_delay"] - - return [ - { - "value": status_count.get("Sent", 0), - "indicator": "green", - "label": "Total Sent", - "datatype": "Int", - }, - { - "value": status_count.get("Deferred", 0), - "indicator": "blue", - "label": "Total Deferred", - "datatype": "Int", - }, - { - "value": status_count.get("Bounced", 0), - "indicator": "red", - "label": "Total Bounced", - "datatype": "Int", - }, - { - "value": total_message_size / len(data) if data else 0, - "indicator": "blue", - "label": "Average Message Size", - "datatype": "Int", - }, - { - "value": total_transfer_delay / len(data) if data else 0, - "indicator": "green", - "label": "Average Transfer Delay", - "datatype": "Float", - }, - ] - - -def get_columns() -> list: +def get_columns() -> list[dict]: return [ { "label": _("Name"), @@ -257,3 +127,128 @@ def get_columns() -> list: "width": 200, }, ] + + +def get_data(filters: dict | None = None) -> list[list]: + filters = filters or {} + + OM = frappe.qb.DocType("Outgoing Mail") + MR = frappe.qb.DocType("Mail Recipient") + + query = ( + frappe.qb.from_(OM) + .left_join(MR) + .on(OM.name == MR.parent) + .select( + OM.name, + OM.creation, + MR.status, + MR.retries, + OM.message_size, + OM.via_api, + OM.is_newsletter, + OM.submitted_after.as_("submission_delay"), + (OM.transfer_started_after + OM.transfer_completed_after).as_("transfer_delay"), + MR.action_after.as_("action_delay"), + ( + OM.submitted_after + + OM.transfer_started_after + + OM.transfer_completed_after + + MR.action_after + ).as_("total_delay"), + OM.agent, + OM.domain_name, + OM.ip_address, + OM.sender, + MR.email.as_("recipient"), + OM.message_id, + ) + .where((OM.docstatus == 1) & (IfNull(MR.status, "") != "")) + .orderby(OM.creation, OM.created_at, order=Order.desc) + .orderby(MR.idx, order=Order.asc) + ) + + if not filters.get("name") and not filters.get("message_id"): + query = query.where( + (Date(OM.created_at) >= Date(filters.get("from_date"))) + & (Date(OM.created_at) <= Date(filters.get("to_date"))) + ) + + if not filters.get("include_newsletter"): + query = query.where(OM.is_newsletter == 0) + + for field in [ + "name", + "agent", + "domain_name", + "ip_address", + "sender", + "message_id", + ]: + if filters.get(field): + query = query.where(OM[field] == filters.get(field)) + + for field in ["status", "email"]: + if filters.get(field): + query = query.where(MR[field] == filters.get(field)) + + user = frappe.session.user + if not is_system_manager(user): + conditions = [] + domains = get_user_owned_domains(user) + mailboxes = get_user_mailboxes(user) + + if has_role(user, "Domain Owner") and domains: + conditions.append(OM.domain_name.isin(domains)) + + if has_role(user, "Mailbox User") and mailboxes: + conditions.append(OM.sender.isin(mailboxes)) + + if not conditions: + return [] + + query = query.where(Criterion.any(conditions)) + + return query.run(as_dict=True) + + +def get_summary(data: list) -> list[dict]: + summary_data = {} + average_data = {} + + for row in data: + for field in ["message_size", "submission_delay", "transfer_delay", "action_delay"]: + key = f"total_{field}" + summary_data.setdefault(key, 0) + summary_data[key] += row[field] + + for key, value in summary_data.items(): + key = key.replace("total_", "") + average_data[key] = flt(value / len(data) if data else 0, 1) + + return [ + { + "label": _("Average Message Size"), + "datatype": "Int", + "value": average_data["message_size"], + "indicator": "green", + }, + { + "label": _("Average Submission Delay"), + "datatype": "Data", + "value": f"{average_data['submission_delay']}s", + "indicator": "yellow", + }, + { + "label": _("Average Transfer Delay"), + "datatype": "Data", + "value": f"{average_data['transfer_delay']}s", + "indicator": "blue", + }, + { + "label": _("Average Action Delay"), + "datatype": "Data", + "value": f"{average_data['action_delay']}s", + "indicator": "orange", + }, + ] diff --git a/mail/mail/report/outgoing_mail_summary/__init__.py b/mail/mail/report/outgoing_mail_summary/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.js b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.js new file mode 100644 index 00000000..6e21dfad --- /dev/null +++ b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.js @@ -0,0 +1,77 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Outgoing Mail Summary"] = { + filters: [ + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.get_today(), -7), + reqd: 1, + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 1, + }, + { + fieldname: "name", + label: __("Outgoing Mail"), + fieldtype: "Link", + options: "Outgoing Mail", + get_query: () => { + return { + query: "mail.utils.query.get_outgoing_mails", + }; + }, + }, + { + fieldname: "status", + label: __("Status"), + fieldtype: "Select", + options: ["", "Deferred", "Bounced", "Sent"], + }, + { + fieldname: "agent", + label: __("Agent"), + fieldtype: "Data", + }, + { + fieldname: "domain_name", + label: __("Domain Name"), + fieldtype: "Link", + options: "Mail Domain", + }, + { + fieldname: "ip_address", + label: __("IP Address"), + fieldtype: "Data", + }, + { + fieldname: "sender", + label: __("Sender"), + fieldtype: "Link", + options: "Mailbox", + }, + { + fieldname: "email", + label: __("Recipient"), + fieldtype: "Data", + options: "Email", + }, + { + fieldname: "message_id", + label: __("Message ID"), + fieldtype: "Data", + }, + { + fieldname: "include_newsletter", + label: __("Include Newsletter"), + fieldtype: "Check", + default: 0, + }, + ], +}; diff --git a/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.json b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.json new file mode 100644 index 00000000..e75287ac --- /dev/null +++ b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.json @@ -0,0 +1,36 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2024-10-18 13:50:12.744195", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2024-10-18 13:50:12.744195", + "modified_by": "Administrator", + "module": "Mail", + "name": "Outgoing Mail Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Outgoing Mail", + "report_name": "Outgoing Mail Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Postmaster" + }, + { + "role": "Domain Owner" + }, + { + "role": "Mailbox User" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py new file mode 100644 index 00000000..74c94504 --- /dev/null +++ b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py @@ -0,0 +1,287 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json +import frappe +from frappe import _ +from datetime import datetime +from frappe.query_builder import Order, Criterion +from mail.utils.cache import get_user_owned_domains +from frappe.query_builder.functions import Date, IfNull +from mail.utils.user import has_role, is_system_manager, get_user_mailboxes + + +def execute(filters: dict | None = None) -> tuple: + columns = get_columns() + data = get_data(filters) + chart = get_chart(data) + summary = get_summary(data) + + return columns, data, None, chart, summary + + +def get_columns() -> list[dict]: + return [ + { + "label": _("Name"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Outgoing Mail", + "width": 100, + }, + { + "label": _("Creation"), + "fieldname": "creation", + "fieldtype": "Datetime", + "width": 180, + }, + { + "label": _("Status"), + "fieldname": "status", + "fieldtype": "Data", + "width": 100, + }, + { + "label": _("Retries"), + "fieldname": "retries", + "fieldtype": "Int", + "width": 80, + }, + { + "label": _("Message Size"), + "fieldname": "message_size", + "fieldtype": "Int", + "width": 120, + }, + { + "label": _("API"), + "fieldname": "via_api", + "fieldtype": "Check", + "width": 60, + }, + { + "label": _("Newsletter"), + "fieldname": "is_newsletter", + "fieldtype": "Check", + "width": 100, + }, + { + "label": _("Response Message"), + "fieldname": "response", + "fieldtype": "Code", + "width": 500, + }, + { + "label": _("Agent"), + "fieldname": "agent", + "fieldtype": "Data", + "width": 150, + }, + { + "label": _("Domain Name"), + "fieldname": "domain_name", + "fieldtype": "Link", + "options": "Mail Domain", + "width": 150, + }, + { + "label": _("IP Address"), + "fieldname": "ip_address", + "fieldtype": "Data", + "width": 120, + }, + { + "label": _("Sender"), + "fieldname": "sender", + "fieldtype": "Link", + "options": "Mailbox", + "width": 200, + }, + { + "label": _("Recipient"), + "fieldname": "recipient", + "fieldtype": "Data", + "width": 200, + }, + { + "label": _("Message ID"), + "fieldname": "message_id", + "fieldtype": "Data", + "width": 200, + }, + ] + + +def get_data(filters: dict | None = None) -> list[list]: + filters = filters or {} + + OM = frappe.qb.DocType("Outgoing Mail") + MR = frappe.qb.DocType("Mail Recipient") + + query = ( + frappe.qb.from_(OM) + .left_join(MR) + .on(OM.name == MR.parent) + .select( + OM.name, + OM.creation, + MR.status, + MR.retries, + OM.message_size, + OM.via_api, + OM.is_newsletter, + MR.details.as_("response"), + OM.agent, + OM.domain_name, + OM.ip_address, + OM.sender, + MR.email.as_("recipient"), + OM.message_id, + ) + .where((OM.docstatus == 1) & (IfNull(MR.status, "") != "")) + .orderby(OM.creation, OM.created_at, order=Order.desc) + .orderby(MR.idx, order=Order.asc) + ) + + if not filters.get("name") and not filters.get("message_id"): + query = query.where( + (Date(OM.created_at) >= Date(filters.get("from_date"))) + & (Date(OM.created_at) <= Date(filters.get("to_date"))) + ) + + if not filters.get("include_newsletter"): + query = query.where(OM.is_newsletter == 0) + + for field in [ + "name", + "agent", + "domain_name", + "ip_address", + "sender", + "message_id", + ]: + if filters.get(field): + query = query.where(OM[field] == filters.get(field)) + + for field in ["status", "email"]: + if filters.get(field): + query = query.where(MR[field] == filters.get(field)) + + user = frappe.session.user + if not is_system_manager(user): + conditions = [] + domains = get_user_owned_domains(user) + mailboxes = get_user_mailboxes(user) + + if has_role(user, "Domain Owner") and domains: + conditions.append(OM.domain_name.isin(domains)) + + if has_role(user, "Mailbox User") and mailboxes: + conditions.append(OM.sender.isin(mailboxes)) + + if not conditions: + return [] + + query = query.where(Criterion.any(conditions)) + + data = query.run(as_dict=True) + + for row in data: + response = json.loads(row["response"]) + row["response"] = ( + response.get("dsn_msg") + or response.get("reason") + or response.get("dsn_smtp_response") + or response.get("response") + ) + + return data + + +def get_chart(data: list) -> list[dict]: + labels, sent, deffered, bounced = [], [], [], [] + + for row in reversed(data): + if isinstance(row["creation"], datetime): + date = row["creation"].date().strftime("%d-%m-%Y") + else: + frappe.throw(_("Invalid date format")) + + if date not in labels: + labels.append(date) + + if row["status"] == "Sent": + sent.append(1) + deffered.append(0) + bounced.append(0) + elif row["status"] == "Deferred": + sent.append(0) + deffered.append(1) + bounced.append(0) + elif row["status"] == "Bounced": + sent.append(0) + deffered.append(0) + bounced.append(1) + else: + sent.append(0) + deffered.append(0) + bounced.append(0) + else: + idx = labels.index(date) + if row["status"] == "Sent": + sent[idx] += 1 + elif row["status"] == "Deferred": + deffered[idx] += 1 + elif row["status"] == "Bounced": + bounced[idx] += 1 + + return { + "data": { + "labels": labels, + "datasets": [ + {"name": "bounced", "values": bounced}, + {"name": "deffered", "values": deffered}, + {"name": "sent", "values": sent}, + ], + }, + "fieldtype": "Int", + "type": "bar", + "axisOptions": {"xIsSeries": -1}, + } + + +def get_summary(data: list) -> list[dict]: + status_count = {} + + for row in data: + status = row["status"] + if status in ["Sent", "Deferred", "Bounced"]: + status_count.setdefault(status, 0) + status_count[status] += 1 + + return [ + { + "label": _("Sent"), + "datatype": "Int", + "value": status_count.get("Sent", 0), + "indicator": "green", + }, + { + "label": _("Deferred"), + "datatype": "Int", + "value": status_count.get("Deferred", 0), + "indicator": "blue", + }, + { + "label": _("Bounced"), + "datatype": "Int", + "value": status_count.get("Bounced", 0), + "indicator": "red", + }, + { + "label": _("Total"), + "datatype": "Int", + "value": len(data), + "indicator": "black", + }, + ]