jcloude/press/api/email.py
2025-12-23 21:34:08 +08:00

330 lines
9.0 KiB
Python

# Copyright (c) 2020, JINGROW
# 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 jingrow
import requests
from jingrow.exceptions import OutgoingEmailError, TooManyRequestsError, ValidationError
from jingrow.utils import validate_email_address
from jingrow.utils.password import get_decrypted_password
from jcloude.api.developer.marketplace import get_subscription_info
from jcloude.api.site import site_config, update_config
from jcloude.utils import log_error
if TYPE_CHECKING:
from jcloude.jcloude.pagetype.jcloude_settings.jcloude_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
@jingrow.whitelist(allow_guest=True)
def email_ping():
return "pong"
def setup(site):
"""
set site config for overriding email account validations
"""
pg_exists = jingrow.db.exists("Mail Setup", {"site": site})
if pg_exists:
pg = jingrow.get_pg("Mail Setup", pg_exists)
if not pg.is_complete:
pg.is_complete = 1
pg.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))
jingrow.get_pg({"pagetype": "Mail Setup", "site": site, "is_complete": 1}).insert(ignore_permissions=True)
@jingrow.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):
jingrow.throw("Invalid Request")
return jingrow.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 = jingrow.conf.email_plans
if not secret_key:
jingrow.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:
jingrow.throw(
str(e)
or "Something went wrong fetching subscription details of Email Delivery Service. Please raise a ticket at support.framework.jingrow.com",
type(e),
)
if not subscription["enabled"]:
jingrow.throw(
"Your subscription is not active. Try reinstalling Email Delivery Service."
f"{jingrow.utils.get_url()}/dashboard/sites/{subscription['site']}/apps",
EmailConfigError,
)
# TODO: add a date filter(use start date from plan)
first_day = str(jingrow.utils.now_datetime().replace(day=1).date())
count = jingrow.db.count(
"Mail Log",
filters={
"site": subscription["site"],
"creation": (">=", first_day),
"subscription_key": secret_key,
},
)
if not count < plan_label_map[subscription["plan"]]:
jingrow.throw(
"You have exceeded your quota for Email Delivery Service. Try upgrading it from, "
f"{jingrow.utils.get_url()}/dashboard/sites/{subscription['site']}/apps",
EmailLimitExceeded,
)
def make_spamd_request(jcloude_settings: PressSettings, message: bytes):
headers = {}
if jcloude_settings.spamd_api_key:
spamd_api_secret = get_decrypted_password("Jcloude Settings", "Jcloude Settings", "spamd_api_secret")
headers["Authorization"] = f"token {jcloude_settings.spamd_api_key}:{spamd_api_secret}"
r = requests.post(
jcloude_settings.spamd_endpoint,
headers=headers,
files={"message": message},
)
r.raise_for_status()
return r.json()
def check_spam(message: bytes):
jcloude_settings = jingrow.get_cached_value(
"Jcloude Settings",
None,
["enable_spam_check", "spamd_endpoint", "spamd_api_key"],
as_dict=True,
)
if not jcloude_settings.enable_spam_check:
return
try:
data = make_spamd_request(jcloude_settings, message)["message"]
score = data.get("spam_score", 0)
spamd_res = data.get("spamd_response")
if score > 4.0:
jingrow.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)
@jingrow.whitelist(allow_guest=True)
def send_mime_mail(**data):
"""
send api request to mailgun
"""
files = jingrow._dict(jingrow.request.files)
data = json.loads(data["data"])
validate_plan(data["sk_mail"])
api_key, domain = jingrow.db.get_value("Jcloude 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")
jingrow.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)
jingrow.throw(
"Something went wrong with sending emails. Please try again later or raise a support ticket with support.framework.jingrow.com",
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
@jingrow.whitelist(allow_guest=True)
def event_log():
"""
log the webhook and forward it to site
"""
data = json.loads(jingrow.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 = (
jingrow.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"]
)
jingrow.get_pg(
{
"pagetype": "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)
jingrow.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 = jingrow.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