330 lines
8.9 KiB
Python
330 lines
8.9 KiB
Python
# Copyright (c) 2020, Frappe and contributors
|
|
# For license information, please see license.txt
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
import json
|
|
import secrets
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
import frappe
|
|
import requests
|
|
from frappe.exceptions import OutgoingEmailError, TooManyRequestsError, ValidationError
|
|
from frappe.utils import validate_email_address
|
|
from frappe.utils.password import get_decrypted_password
|
|
|
|
from press.api.developer.marketplace import get_subscription_info
|
|
from press.api.site import site_config, update_config
|
|
from press.utils import log_error
|
|
|
|
if TYPE_CHECKING:
|
|
from press.press.doctype.press_settings.press_settings import PressSettings
|
|
|
|
|
|
class EmailLimitExceeded(TooManyRequestsError):
|
|
pass
|
|
|
|
|
|
class EmailSendError(OutgoingEmailError):
|
|
pass
|
|
|
|
|
|
class InvalidEmail(ValidationError):
|
|
http_status_code = 400
|
|
|
|
|
|
class EmailConfigError(ValidationError):
|
|
http_status_code = 400
|
|
|
|
|
|
class SpamDetectionError(ValidationError):
|
|
http_status_code = 422
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def email_ping():
|
|
return "pong"
|
|
|
|
|
|
def setup(site):
|
|
"""
|
|
set site config for overriding email account validations
|
|
"""
|
|
doc_exists = frappe.db.exists("Mail Setup", {"site": site})
|
|
|
|
if doc_exists:
|
|
doc = frappe.get_doc("Mail Setup", doc_exists)
|
|
|
|
if not doc.is_complete:
|
|
doc.is_complete = 1
|
|
doc.save()
|
|
|
|
return
|
|
|
|
old_config = site_config(site)
|
|
|
|
new_config = [
|
|
{"key": "mail_login", "value": "example@gmail.com", "type": "String"},
|
|
{"key": "mail_password", "value": "password", "type": "String"},
|
|
{"key": "mail_port", "value": 587, "type": "Number"},
|
|
{"key": "mail_server", "value": "smtp.gmail.com", "type": "String"},
|
|
]
|
|
for row in old_config:
|
|
new_config.append({"key": row.key, "value": row.value, "type": row.type})
|
|
|
|
update_config(site, json.dumps(new_config))
|
|
|
|
frappe.get_doc({"doctype": "Mail Setup", "site": site, "is_complete": 1}).insert(ignore_permissions=True)
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def get_analytics(**data):
|
|
"""
|
|
send data for a specific month
|
|
"""
|
|
month = data.get("month")
|
|
year = datetime.now().year
|
|
last_day = calendar.monthrange(year, int(month))[1]
|
|
status = data.get("status")
|
|
site = data.get("site")
|
|
subscription_key = data.get("key")
|
|
|
|
for value in (site, subscription_key):
|
|
if not value or not isinstance(value, str):
|
|
frappe.throw("Invalid Request")
|
|
|
|
return frappe.get_all(
|
|
"Mail Log",
|
|
filters={
|
|
"site": site,
|
|
"subscription_key": subscription_key,
|
|
"status": ["like", f"%{status}%"],
|
|
"date": ["between", [f"{month}-01-{year}", f"{month}-{last_day}-{year}"]],
|
|
},
|
|
fields=["date", "status", "message", "sender", "recipient"],
|
|
order_by="date asc",
|
|
)
|
|
|
|
|
|
def validate_plan(secret_key):
|
|
"""
|
|
check if subscription is active on marketplace and valid
|
|
#TODO: get activation date
|
|
"""
|
|
|
|
# TODO: replace this with plan attributes
|
|
plan_label_map = frappe.conf.email_plans
|
|
|
|
if not secret_key:
|
|
frappe.throw(
|
|
"Secret key missing. Email Delivery Service seems to be improperly installed. Try reinstalling it.",
|
|
EmailConfigError,
|
|
)
|
|
|
|
try:
|
|
subscription = get_subscription_info(secret_key=secret_key)
|
|
except Exception as e:
|
|
frappe.throw(
|
|
str(e)
|
|
or "Something went wrong fetching subscription details of Email Delivery Service. Please raise a ticket at support.frappe.io",
|
|
type(e),
|
|
)
|
|
|
|
if not subscription["enabled"]:
|
|
frappe.throw(
|
|
"Your subscription is not active. Try reinstalling Email Delivery Service."
|
|
f"{frappe.utils.get_url()}/dashboard/sites/{subscription['site']}/apps",
|
|
EmailConfigError,
|
|
)
|
|
|
|
# TODO: add a date filter(use start date from plan)
|
|
first_day = str(frappe.utils.now_datetime().replace(day=1).date())
|
|
count = frappe.db.count(
|
|
"Mail Log",
|
|
filters={
|
|
"site": subscription["site"],
|
|
"creation": (">=", first_day),
|
|
"subscription_key": secret_key,
|
|
},
|
|
)
|
|
if not count < plan_label_map[subscription["plan"]]:
|
|
frappe.throw(
|
|
"You have exceeded your quota for Email Delivery Service. Try upgrading it from, "
|
|
f"{frappe.utils.get_url()}/dashboard/sites/{subscription['site']}/apps",
|
|
EmailLimitExceeded,
|
|
)
|
|
|
|
|
|
def make_spamd_request(press_settings: PressSettings, message: bytes):
|
|
headers = {}
|
|
if press_settings.spamd_api_key:
|
|
spamd_api_secret = get_decrypted_password("Press Settings", "Press Settings", "spamd_api_secret")
|
|
headers["Authorization"] = f"token {press_settings.spamd_api_key}:{spamd_api_secret}"
|
|
r = requests.post(
|
|
press_settings.spamd_endpoint,
|
|
headers=headers,
|
|
files={"message": message},
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def check_spam(message: bytes):
|
|
press_settings = frappe.get_cached_value(
|
|
"Press Settings",
|
|
None,
|
|
["enable_spam_check", "spamd_endpoint", "spamd_api_key"],
|
|
as_dict=True,
|
|
)
|
|
if not press_settings.enable_spam_check:
|
|
return
|
|
try:
|
|
data = make_spamd_request(press_settings, message)["message"]
|
|
score = data.get("spam_score", 0)
|
|
spamd_res = data.get("spamd_response")
|
|
if score > 4.0:
|
|
frappe.throw(
|
|
f"""This email was blocked as it was flagged as spam. Please review documentation corresponding to the error codes below:
|
|
|
|
docs: https://spamassassin.apache.org/old/tests_3_3_x.html
|
|
|
|
{spamd_res}""",
|
|
SpamDetectionError,
|
|
)
|
|
except requests.exceptions.HTTPError as e:
|
|
# Ignore error, if server.frappemail.com is being updated.
|
|
if e.response.status_code != 503:
|
|
log_error("Spam Detection : Error", data=e)
|
|
|
|
|
|
def check_recipients(recipients: str | list[str]):
|
|
if isinstance(recipients, str):
|
|
validate_email_address(recipients, throw=True)
|
|
elif isinstance(recipients, list):
|
|
for recipient in recipients:
|
|
validate_email_address(recipient, throw=True)
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def send_mime_mail(**data):
|
|
"""
|
|
send api request to mailgun
|
|
"""
|
|
files = frappe._dict(frappe.request.files)
|
|
data = json.loads(data["data"])
|
|
|
|
validate_plan(data["sk_mail"])
|
|
|
|
api_key, domain = frappe.db.get_value("Press Settings", None, ["mailgun_api_key", "root_domain"])
|
|
|
|
message: bytes = files["mime"].read()
|
|
check_spam(message)
|
|
check_recipients(data["recipients"])
|
|
|
|
resp = requests.post(
|
|
f"https://api.mailgun.net/v3/{domain}/messages.mime",
|
|
auth=("api", f"{api_key}"),
|
|
data={"to": data["recipients"], "v:sk_mail": data["sk_mail"]},
|
|
files={"message": message},
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
return "Sending" # Not really required as v14 and up automatically marks the email q as sent
|
|
if resp.status_code == 400:
|
|
err_msg: str = resp.json().get("message", "Invalid request")
|
|
frappe.throw(f"Something went wrong with sending emails: {err_msg}", InvalidEmail)
|
|
log_error("Email Delivery Service: Sending error", response=resp.text, data=data, message=message)
|
|
frappe.throw(
|
|
"Something went wrong with sending emails. Please try again later or raise a support ticket with support.frappe.io",
|
|
EmailSendError,
|
|
)
|
|
return None
|
|
|
|
|
|
def is_valid_mailgun_event(event_data):
|
|
if not event_data:
|
|
return None
|
|
|
|
if event_data.get("user-variables", {}).get("sk_mail") is None:
|
|
# We don't know where to send this event
|
|
# TOOD: Investigate why this is happening
|
|
# Hint: Likely from other emails not sent via the email delivery app
|
|
return None
|
|
|
|
if "delivery-status" not in event_data:
|
|
return None
|
|
|
|
if "message" not in event_data["delivery-status"]:
|
|
return None
|
|
|
|
return True
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def event_log():
|
|
"""
|
|
log the webhook and forward it to site
|
|
"""
|
|
data = json.loads(frappe.request.data)
|
|
event_data = data.get("event-data")
|
|
|
|
if not is_valid_mailgun_event(event_data):
|
|
return None
|
|
|
|
try:
|
|
secret_key = event_data["user-variables"]["sk_mail"]
|
|
headers = event_data["message"]["headers"]
|
|
if "message-id" not in headers:
|
|
# We can't log this event without a message-id
|
|
# TOOD: Investigate why this is happening
|
|
return None
|
|
message_id = headers["message-id"]
|
|
site = (
|
|
frappe.get_cached_value("Subscription", {"secret_key": secret_key}, "site")
|
|
or message_id.split("@")[1]
|
|
)
|
|
status = event_data["event"]
|
|
delivery_message = (
|
|
event_data["delivery-status"]["message"] or event_data["delivery-status"]["description"]
|
|
)
|
|
frappe.get_doc(
|
|
{
|
|
"doctype": "Mail Log",
|
|
"unique_token": secrets.token_hex(25),
|
|
"message_id": message_id,
|
|
"sender": headers["from"],
|
|
"recipient": event_data.get("recipient") or headers.get("to"),
|
|
"site": site,
|
|
"status": event_data["event"],
|
|
"subscription_key": secret_key,
|
|
"message": delivery_message,
|
|
"log": json.dumps(data),
|
|
}
|
|
).insert(ignore_permissions=True)
|
|
frappe.db.commit()
|
|
except Exception:
|
|
log_error("Mail App: Event log error", data=data)
|
|
raise
|
|
|
|
data = {
|
|
"status": status,
|
|
"message_id": message_id,
|
|
"delivery_message": delivery_message,
|
|
"secret_key": secret_key,
|
|
}
|
|
|
|
try:
|
|
host_name = frappe.db.get_value("Site", site, "host_name") or site
|
|
requests.post(
|
|
f"https://{host_name}/api/method/email_delivery_service.controller.update_status",
|
|
data=data,
|
|
)
|
|
except requests.exceptions.ConnectionError:
|
|
# site might be down or unreachable
|
|
pass
|
|
except Exception as e:
|
|
log_error("Mail App: Email status update error", data=e)
|
|
|
|
return "Successful", 200
|