174 lines
4.8 KiB
Python
174 lines
4.8 KiB
Python
import math
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from urllib.parse import urljoin
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import requests
|
|
|
|
import frappe
|
|
from frappe import _
|
|
from frappe.frappeclient import FrappeClient, FrappeOAuth2Client
|
|
from frappe.utils import convert_utc_to_system_timezone, get_datetime, get_system_timezone
|
|
|
|
CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
|
|
|
|
|
class FrappeMail:
|
|
"""Class to interact with the Frappe Mail API."""
|
|
|
|
def __init__(
|
|
self,
|
|
site: str,
|
|
email: str,
|
|
api_key: str | None = None,
|
|
api_secret: str | None = None,
|
|
access_token: str | None = None,
|
|
) -> None:
|
|
self.site = site
|
|
self.email = email
|
|
self.api_key = api_key
|
|
self.api_secret = api_secret
|
|
self.access_token = access_token
|
|
self.client = self.get_client(self.site, self.email, self.api_key, self.api_secret, self.access_token)
|
|
|
|
@staticmethod
|
|
def get_client(
|
|
site: str,
|
|
email: str,
|
|
api_key: str | None = None,
|
|
api_secret: str | None = None,
|
|
access_token: str | None = None,
|
|
) -> FrappeClient | FrappeOAuth2Client:
|
|
"""Returns a FrappeClient or FrappeOAuth2Client instance."""
|
|
|
|
if hasattr(frappe.local, "frappe_mail_clients"):
|
|
if client := frappe.local.frappe_mail_clients.get(email):
|
|
return client
|
|
else:
|
|
frappe.local.frappe_mail_clients = {}
|
|
|
|
client = (
|
|
FrappeOAuth2Client(url=site, access_token=access_token)
|
|
if access_token
|
|
else FrappeClient(url=site, api_key=api_key, api_secret=api_secret)
|
|
)
|
|
frappe.local.frappe_mail_clients[email] = client
|
|
|
|
return client
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
endpoint: str,
|
|
params: dict | None = None,
|
|
data: dict | None = None,
|
|
json: dict | None = None,
|
|
files: dict | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: int | tuple[int, int] = (60, 120),
|
|
) -> Any | None:
|
|
"""Makes a request to the Frappe Mail API."""
|
|
|
|
url = urljoin(self.client.url, endpoint)
|
|
|
|
headers = headers or {}
|
|
headers.update(self.client.headers)
|
|
|
|
if files:
|
|
headers.pop("content-type", None)
|
|
|
|
response = self.client.session.request(
|
|
method=method,
|
|
url=url,
|
|
params=params,
|
|
data=data,
|
|
json=json,
|
|
files=files,
|
|
headers=headers,
|
|
timeout=timeout,
|
|
)
|
|
raise_for_status(response)
|
|
|
|
return self.client.post_process(response)
|
|
|
|
def validate(self) -> None:
|
|
"""Validates if the user is allowed to send or receive emails."""
|
|
|
|
endpoint = "/api/method/mail.api.auth.validate"
|
|
data = {"email": self.email}
|
|
self.request("POST", endpoint=endpoint, data=data)
|
|
|
|
def send_raw(
|
|
self, sender: str, recipients: str | list, message: str | bytes, is_newsletter: bool = False
|
|
) -> None:
|
|
"""Sends an email using the Frappe Mail API."""
|
|
|
|
session_id = str(uuid.uuid4())
|
|
endpoint = "/api/method/mail.api.outbound.send_raw"
|
|
|
|
if isinstance(message, str):
|
|
message = message.encode("utf-8")
|
|
|
|
total_size = len(message)
|
|
total_chunks = math.ceil(total_size / CHUNK_SIZE)
|
|
|
|
for i in range(total_chunks):
|
|
start = i * CHUNK_SIZE
|
|
end = start + CHUNK_SIZE
|
|
chunk = message[start:end]
|
|
|
|
files = {"raw_message": ("raw_message.eml", chunk)}
|
|
data = {
|
|
"from_": sender,
|
|
"to": recipients,
|
|
"is_newsletter": is_newsletter,
|
|
"uuid": session_id,
|
|
"chunk_index": i,
|
|
"total_chunk_count": total_chunks,
|
|
"chunk_byte_offset": start,
|
|
}
|
|
self.request("POST", endpoint=endpoint, data=data, files=files)
|
|
|
|
def pull_raw(
|
|
self, mailbox: str = "inbox", limit: int = 50, last_received_at: str | None = None
|
|
) -> dict[str, str | list[str]]:
|
|
"""Pull emails for the account using the Frappe Mail API."""
|
|
|
|
endpoint = "/api/method/mail.api.inbound.pull_raw"
|
|
if last_received_at:
|
|
last_received_at = add_or_update_tzinfo(last_received_at)
|
|
|
|
data = {"mailbox": mailbox, "limit": limit, "last_received_at": last_received_at}
|
|
headers = {"X-Site": frappe.utils.get_url()}
|
|
response = self.request("GET", endpoint=endpoint, data=data, headers=headers)
|
|
last_received_at = convert_utc_to_system_timezone(get_datetime(response["last_received_at"]))
|
|
|
|
return {"latest_messages": response["mails"], "last_received_at": last_received_at}
|
|
|
|
|
|
def add_or_update_tzinfo(date_time: datetime | str, timezone: str | None = None) -> str:
|
|
"""Adds or updates timezone to the datetime."""
|
|
date_time = get_datetime(date_time)
|
|
target_tz = ZoneInfo(timezone or get_system_timezone())
|
|
|
|
if date_time.tzinfo is None:
|
|
date_time = date_time.replace(tzinfo=target_tz)
|
|
else:
|
|
date_time = date_time.astimezone(target_tz)
|
|
|
|
return str(date_time)
|
|
|
|
|
|
def raise_for_status(response: requests.Response) -> None:
|
|
"""Raises an HTTPError if the response status code indicates an error."""
|
|
|
|
if not response.ok:
|
|
try:
|
|
error_text = response.json()
|
|
except Exception:
|
|
error_text = response.text.strip()
|
|
|
|
message = _("Error {0}: {1}").format(response.status_code, error_text)
|
|
raise requests.exceptions.HTTPError(message, response=response)
|