Spaces:
Sleeping
Sleeping
File size: 8,794 Bytes
45dc2a1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 | """
Settings, in two scopes:
- Personal (per person, per org): the name shown in the console and which notices they want.
Any member edits their own.
- Organisation: the org's name (owner or admin) and how its emails go out (operator or above):
the sender name beside the address, a reply-to address, the mailbox the composer picks by
default, and whether a copy of each email is filed in the mailbox's Sent folder.
Defaults live here, so a setting nobody has touched still reads as a full set of values.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from backend import firestore_client as db
from backend import mailboxes, orgs
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_MAX_NAME = 80
NOTIFICATION_KEYS = ("campaign_finished", "emails_turned_away", "mailbox_problem", "new_signup", "weekly_summary")
DEFAULT_NOTIFICATIONS = {
"campaign_finished": False,
"emails_turned_away": True,
"mailbox_problem": True,
"new_signup": False,
"weekly_summary": False,
}
DEFAULT_EMAILS = {"from_name": "", "reply_to": "", "default_mailbox_id": "", "save_sent_copy": True, "pause_seconds": 2}
# Seconds M8ion waits between two emails of a campaign. Some mail servers accept a fast burst and
# then never deliver it, so campaigns are spaced out unless the organisation turns this down.
MAX_PAUSE_SECONDS = 30
ORG_PROFILE_FIELDS = ("website", "domain", "country", "state", "lga", "address", "postal_code", "timezone")
_DOMAIN_RE = re.compile(r"^(?=.{3,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _personal_path(org_id: str, uid: str) -> str:
return f"orgs/{org_id}/personal_settings/{uid}"
def _emails_path(org_id: str) -> str:
return f"orgs/{org_id}/settings/emails"
def _clean_name(value: str, what: str) -> str:
value = " ".join(str(value or "").split())
if len(value) > _MAX_NAME:
raise ValueError(f"Keep the {what} to {_MAX_NAME} characters or fewer")
return value
def get_settings(org_id: str, uid: str) -> dict:
"""Everything the Settings pages show, with defaults filled in."""
role = orgs.require_permission(org_id, uid)
permissions = sorted(orgs.permissions_for(org_id, role))
personal = db.get_doc(_personal_path(org_id, uid)) or {}
return {
"role": role,
"permissions": permissions,
"profile": profile_of(org_id, uid, personal),
"organisation": organisation(org_id),
"notifications": DEFAULT_NOTIFICATIONS | {k: bool(v) for k, v in (personal.get("notifications") or {}).items() if k in NOTIFICATION_KEYS},
"emails": email_settings(org_id),
}
def email_settings(org_id: str) -> dict:
"""How the org's emails go out. Read by the send itself, so it takes no actor."""
return DEFAULT_EMAILS | {k: v for k, v in (db.get_doc(_emails_path(org_id)) or {}).items() if k in DEFAULT_EMAILS}
PROFILE_EXTRAS = ("job_title", "phone", "about")
def profile_of(org_id: str, uid: str, personal: dict | None = None) -> dict:
"""The person's own profile: what they chose to share, plus facts about their use of this organisation."""
personal = personal if personal is not None else (db.get_doc(_personal_path(org_id, uid)) or {})
member = db.get_doc(f"orgs/{org_id}/members/{uid}") or {}
sends = [d for d in db.query_prefix(f"orgs/{org_id}/sends/").values() if d.get("created_by") == uid]
return {
"display_name": personal.get("display_name", ""),
**{k: personal.get(k, "") for k in PROFILE_EXTRAS},
"active_since": member.get("added_at"),
"campaigns_sent": len(sends),
"last_active": max((d.get("created_at") or "" for d in sends), default="") or None,
}
def update_profile(org_id: str, uid: str, display_name: str, extras: dict | None = None) -> dict:
orgs.require_permission(org_id, uid)
name = _clean_name(display_name, "name")
update = {"display_name": name}
for key in PROFILE_EXTRAS:
if extras and key in extras:
limit = 500 if key == "about" else 80
value = " ".join(str(extras[key] or "").split()) if key != "about" else str(extras[key] or "").strip()
if len(value) > limit:
raise ValueError(f"Keep this to {limit} characters or fewer")
update[key] = value
_merge(_personal_path(org_id, uid), update)
return profile_of(org_id, uid)
def update_notifications(org_id: str, uid: str, values: dict) -> dict:
orgs.require_permission(org_id, uid)
unknown = set(values) - set(NOTIFICATION_KEYS)
if unknown:
raise ValueError(f"Unknown notice: {', '.join(sorted(unknown))}")
current = get_settings(org_id, uid)["notifications"] | {k: bool(v) for k, v in values.items()}
_merge(_personal_path(org_id, uid), {"notifications": current})
return current
def organisation(org_id: str) -> dict:
org = orgs.get_org(org_id)
profile = org.get("profile") or {}
return {"name": org["name"], **{k: profile.get(k, "") for k in ORG_PROFILE_FIELDS}}
def rename_org(org_id: str, uid: str, name: str, profile: dict | None = None) -> dict:
"""Saves the org's name and its details: website, main domain, country, state, LGA, postal
address and postal code (many countries' email laws ask for one in marketing emails) and time zone."""
orgs.require_permission(org_id, uid, "manage_settings")
name = _clean_name(name, "organisation name")
if not name:
raise ValueError("Give the organisation a name")
update: dict = {"name": name}
if profile is not None:
unknown = set(profile) - set(ORG_PROFILE_FIELDS)
if unknown:
raise ValueError(f"Unknown organisation detail: {', '.join(sorted(unknown))}")
clean = {k: " ".join(str(profile.get(k) or "").split()) for k in ORG_PROFILE_FIELDS}
if len(clean["address"]) > 300:
raise ValueError("Keep the address to 300 characters or fewer")
if len(clean["postal_code"]) > 20:
raise ValueError("Keep the postal code to 20 characters or fewer")
for key in ("country", "state", "lga"):
if len(clean[key]) > 100:
raise ValueError(f"Keep the {key} to 100 characters or fewer")
clean["domain"] = clean["domain"].lower().removeprefix("https://").removeprefix("http://").removeprefix("www.").rstrip("/")
if clean["domain"] and not _DOMAIN_RE.match(clean["domain"]):
raise ValueError("Enter a domain like yourcompany.com")
if clean["website"] and not clean["website"].startswith(("https://", "http://")):
clean["website"] = "https://" + clean["website"]
if clean["timezone"]:
try:
from zoneinfo import ZoneInfo
ZoneInfo(clean["timezone"])
except Exception as exc: # noqa: BLE001
raise ValueError("Pick a time zone from the list") from exc
update["profile"] = clean
db.update_doc(f"orgs/{org_id}", update)
return organisation(org_id)
def update_emails(org_id: str, uid: str, values: dict) -> dict:
orgs.require_permission(org_id, uid, "manage_settings")
unknown = set(values) - set(DEFAULT_EMAILS)
if unknown:
raise ValueError(f"Unknown email setting: {', '.join(sorted(unknown))}")
clean: dict = {}
if "from_name" in values:
clean["from_name"] = _clean_name(values["from_name"], "sender name").replace('"', "")
if "reply_to" in values:
reply_to = str(values["reply_to"] or "").strip()
if reply_to and not _EMAIL_RE.match(reply_to):
raise ValueError("The reply-to address does not look like an email address")
clean["reply_to"] = reply_to
if "default_mailbox_id" in values:
mailbox_id = str(values["default_mailbox_id"] or "")
if mailbox_id:
mailboxes.get_mailbox(org_id, mailbox_id) # raises NotFoundError for another org's id
clean["default_mailbox_id"] = mailbox_id
if "save_sent_copy" in values:
clean["save_sent_copy"] = bool(values["save_sent_copy"])
if "pause_seconds" in values:
try:
pause = float(values["pause_seconds"] or 0)
except (TypeError, ValueError) as exc:
raise ValueError("The pause must be a number of seconds") from exc
if not 0 <= pause <= MAX_PAUSE_SECONDS:
raise ValueError(f"The pause must be between 0 and {MAX_PAUSE_SECONDS} seconds")
clean["pause_seconds"] = pause
_merge(_emails_path(org_id), clean)
return email_settings(org_id)
def _merge(path: str, values: dict) -> None:
doc = db.get_doc(path) or {}
db.set_doc(path, doc | values | {"updated_at": _now()})
|