Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
9.0 KiB
Python
285 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Create real person contacts in Barbara's M365 Contacts folder from missing contacts list."""
|
|
|
|
import json
|
|
import subprocess
|
|
import re
|
|
import time
|
|
|
|
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
|
|
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
|
|
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
|
|
USER = "barbara@bardach.net"
|
|
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
|
MIN_MESSAGES = 4
|
|
BARBARAS_PHONE = "(520) 275-3867"
|
|
|
|
# Commercial domains to exclude
|
|
COMMERCIAL_DOMAINS = {
|
|
"monos.com", "zestypaws.com", "augustinusbader.com", "ella-bella.com",
|
|
"thefarmersdog.com", "nordprotect.zendesk.com", "hilton.com", "orhp.com",
|
|
"havenlifestyles.com", "4unature.com", "skyslope.com", "arcisgolf.com",
|
|
"tucsonrealtors.org"
|
|
}
|
|
|
|
# Commercial keywords in display_name (case-insensitive)
|
|
COMMERCIAL_NAME_KEYWORDS = [
|
|
"team", "support", "reception", "frontdesk", "nordprotect", "zesty", "monos"
|
|
]
|
|
|
|
# Commercial email prefixes
|
|
COMMERCIAL_EMAIL_PREFIXES = [
|
|
"care@", "hello@", "connect@", "contact@", "bark@", "support+",
|
|
"justchecking", "ticketing@"
|
|
]
|
|
|
|
# Title suffixes to drop when parsing names
|
|
TITLE_SUFFIXES = [
|
|
"office manager", "broker", "agent", "realtor", "manager", "director",
|
|
"assistant", "coordinator", "specialist", "advisor", "consultant"
|
|
]
|
|
|
|
|
|
def get_token():
|
|
"""Get OAuth token via client credentials."""
|
|
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
|
|
cmd = [
|
|
"curl", "-s", "-X", "POST", url,
|
|
"-H", "Content-Type: application/x-www-form-urlencoded",
|
|
"-d", f"client_id={CLIENT_ID}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret={CLIENT_SECRET}&grant_type=client_credentials"
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
data = json.loads(result.stdout)
|
|
if "access_token" not in data:
|
|
print(f"[ERROR] Token failed: {data}")
|
|
return None
|
|
return data["access_token"]
|
|
|
|
|
|
def is_email_like(name):
|
|
"""Check if display_name is just an email address."""
|
|
return "@" in name and "." in name
|
|
|
|
|
|
def is_commercial(contact):
|
|
"""Check if a contact is commercial/automated."""
|
|
email = contact["email"].lower()
|
|
name = contact["display_name"].lower()
|
|
domain = email.split("@")[-1] if "@" in email else ""
|
|
|
|
# Own email
|
|
if email == "bardach@bardach.net":
|
|
return True
|
|
|
|
# No-reply patterns
|
|
if any(x in email for x in ["noreply", "no-reply", "donotreply"]):
|
|
return True
|
|
|
|
# Commercial domains
|
|
if domain in COMMERCIAL_DOMAINS:
|
|
return True
|
|
|
|
# Commercial name keywords
|
|
for kw in COMMERCIAL_NAME_KEYWORDS:
|
|
if kw in name:
|
|
return True
|
|
|
|
# Commercial email prefixes
|
|
for prefix in COMMERCIAL_EMAIL_PREFIXES:
|
|
if email.startswith(prefix) or prefix in email:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def parse_name(display_name):
|
|
"""Parse display_name into (givenName, surname)."""
|
|
name = display_name.strip()
|
|
|
|
# Handle "Last, First" format
|
|
if "," in name:
|
|
parts = [p.strip() for p in name.split(",", 1)]
|
|
if len(parts) == 2 and parts[0] and parts[1]:
|
|
first = parts[1].split()[0] # Take first word after comma
|
|
return first, parts[0]
|
|
|
|
# Split into words
|
|
words = name.split()
|
|
if len(words) == 0:
|
|
return "", ""
|
|
if len(words) == 1:
|
|
return words[0], ""
|
|
if len(words) == 2:
|
|
return words[0], words[1]
|
|
|
|
# 3+ words: check for title suffixes
|
|
# Try to find where a title suffix starts
|
|
lower_name = name.lower()
|
|
for suffix in TITLE_SUFFIXES:
|
|
idx = lower_name.find(suffix)
|
|
if idx > 0:
|
|
# Take everything before the suffix
|
|
name_part = name[:idx].strip()
|
|
name_words = name_part.split()
|
|
if len(name_words) >= 2:
|
|
return name_words[0], " ".join(name_words[1:])
|
|
elif len(name_words) == 1:
|
|
return name_words[0], ""
|
|
|
|
# Default: first word = given, second word = surname, ignore rest
|
|
return words[0], words[1]
|
|
|
|
|
|
def build_contact_payload(contact):
|
|
"""Build the JSON payload for creating a contact."""
|
|
given, surname = parse_name(contact["display_name"])
|
|
payload = {
|
|
"givenName": given,
|
|
"surname": surname,
|
|
"displayName": contact["display_name"],
|
|
"emailAddresses": [
|
|
{"address": contact["email"], "name": contact["display_name"]}
|
|
]
|
|
}
|
|
|
|
phone = contact.get("phone")
|
|
label = (contact.get("phone_label") or "").strip()
|
|
|
|
if phone and phone != BARBARAS_PHONE:
|
|
label_lower = label.lower()
|
|
if label_lower == "fax":
|
|
pass # Skip fax
|
|
elif label_lower in ("cell", "mobile"):
|
|
payload["mobilePhone"] = phone
|
|
elif label_lower in ("home",):
|
|
payload["homePhones"] = [phone]
|
|
else:
|
|
# Office, Direct, Phone, empty -> businessPhones
|
|
payload["businessPhones"] = [phone]
|
|
|
|
return payload
|
|
|
|
|
|
def create_contact(token, payload):
|
|
"""Create a contact via Graph API."""
|
|
url = f"{GRAPH_BASE}/users/{USER}/contacts"
|
|
json_str = json.dumps(payload)
|
|
cmd = [
|
|
"curl", "-s", "-X", "POST", url,
|
|
"-H", f"Authorization: Bearer {token}",
|
|
"-H", "Content-Type: application/json",
|
|
"-d", json_str
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
try:
|
|
data = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
return None, result.stdout
|
|
return data, None
|
|
|
|
|
|
def main():
|
|
# Load data
|
|
with open(r"D:\ClaudeTools\temp\bardach_missing_real_contacts.json", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
contacts = data["contacts"]
|
|
print(f"[INFO] Loaded {len(contacts)} total missing contacts")
|
|
|
|
# Filter: minimum messages
|
|
contacts = [c for c in contacts if c["total"] >= MIN_MESSAGES]
|
|
print(f"[INFO] After >= {MIN_MESSAGES} messages filter: {len(contacts)}")
|
|
|
|
# Filter: remove empty/email-only display names
|
|
filtered = []
|
|
removed_reasons = []
|
|
for c in contacts:
|
|
name = (c["display_name"] or "").strip()
|
|
email = c["email"].lower()
|
|
|
|
if not name:
|
|
removed_reasons.append(f" REMOVED (empty name): {email}")
|
|
continue
|
|
if is_email_like(name):
|
|
removed_reasons.append(f" REMOVED (email-as-name): {name} <{email}>")
|
|
continue
|
|
if is_commercial(c):
|
|
removed_reasons.append(f" REMOVED (commercial): {name} <{email}>")
|
|
continue
|
|
filtered.append(c)
|
|
|
|
print(f"[INFO] Removed {len(contacts) - len(filtered)} non-person entries:")
|
|
for r in removed_reasons:
|
|
print(r)
|
|
|
|
print(f"\n[INFO] Final filtered list: {len(filtered)} real person contacts\n")
|
|
|
|
# Print the filtered list for review
|
|
print(f"{'#':<4} {'Name':<35} {'Email':<45} {'Phone':<18} {'Msgs':>5}")
|
|
print("-" * 110)
|
|
has_phone_count = 0
|
|
for i, c in enumerate(filtered, 1):
|
|
phone = c.get("phone") or ""
|
|
if phone == BARBARAS_PHONE:
|
|
phone = "(skipped-own)"
|
|
if phone and phone != "(skipped-own)":
|
|
has_phone_count += 1
|
|
label = c.get("phone_label") or ""
|
|
phone_display = f"{phone} [{label}]" if label else phone
|
|
print(f"{i:<4} {c['display_name']:<35} {c['email']:<45} {phone_display:<18} {c['total']:>5}")
|
|
|
|
print(f"\n[INFO] {has_phone_count} contacts have phone numbers")
|
|
print(f"[INFO] Starting contact creation...\n")
|
|
|
|
# Get token
|
|
token = get_token()
|
|
if not token:
|
|
print("[ERROR] Could not get token. Aborting.")
|
|
return
|
|
|
|
created = 0
|
|
errors = 0
|
|
with_phone = 0
|
|
|
|
for i, c in enumerate(filtered):
|
|
# Refresh token every 30 creates
|
|
if i > 0 and i % 30 == 0:
|
|
print(f"[INFO] Refreshing token after {i} contacts...")
|
|
token = get_token()
|
|
if not token:
|
|
print("[ERROR] Token refresh failed. Aborting.")
|
|
return
|
|
|
|
payload = build_contact_payload(c)
|
|
has_phone = "businessPhones" in payload or "mobilePhone" in payload or "homePhones" in payload
|
|
|
|
resp, err = create_contact(token, payload)
|
|
if err:
|
|
print(f"[ERROR] {c['display_name']} <{c['email']}>: curl error: {err}")
|
|
errors += 1
|
|
continue
|
|
|
|
if "id" in resp:
|
|
phone_note = " (with phone)" if has_phone else ""
|
|
print(f"[CREATED] {c['display_name']} <{c['email']}>{phone_note}")
|
|
created += 1
|
|
if has_phone:
|
|
with_phone += 1
|
|
else:
|
|
err_code = resp.get("error", {}).get("code", "unknown")
|
|
err_msg = resp.get("error", {}).get("message", str(resp))
|
|
print(f"[ERROR] {c['display_name']} <{c['email']}>: {err_code} - {err_msg}")
|
|
errors += 1
|
|
|
|
print(f"\n{'=' * 60}")
|
|
print(f"[SUMMARY]")
|
|
print(f" Total filtered contacts: {len(filtered)}")
|
|
print(f" Created: {created}")
|
|
print(f" With phone: {with_phone}")
|
|
print(f" Errors: {errors}")
|
|
print(f"{'=' * 60}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|