chore: gitignore temp/ scratch dir and untrack it

temp/ is local scratch (probe drafts, JSON dumps, debug scripts). It was being
swept into every /save by sync.sh's git add -A. Now ignored + untracked (files
remain on disk; history unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:54:02 -07:00
parent c3b03986c8
commit 95022f4814
124 changed files with 1 additions and 865136 deletions

1
.gitignore vendored
View File

@@ -92,3 +92,4 @@ projects/radio-show/audio-processor/*.egg-info/
clients/internal-infrastructure/datto-bsod-case-2026-05-16.zip
clients/internal-infrastructure/datto-bsod-case-2026-05-16/
temp/

View File

@@ -1,196 +0,0 @@
# SPEC-005: Integration Catalog ("Integration Center")
**Status:** Approved for build (planning locked 2026-05-29)
**Priority:** P2 — built in tandem with SPEC-002 (Syncro PSA)
**Requested By:** Mike Swanson (2026-05-18; scope locked 2026-05-29)
**Estimated Effort:** Large (catalog + Syncro plugin + MSP360 migration)
---
## Overview
The GuruRMM **Integration Center** is a centralized, **partner-scoped** surface for managing third-party integrations (Syncro PSA, MSP360 Managed Backup, and future providers). Each **partner (MSP)** browses the available catalog, configures the integrations they want for their own tenant, and monitors health/status — without per-tool fragmentation.
This is an **internal catalog**, not a public marketplace. App-store-style discovery, monetization/entitlement, and third-party-submitted integrations are explicitly **out of scope** (may be revisited as a separate "Store" initiative later).
**Audience:** Partner-level users (ADR-001 Dev -> Partner -> Client model). Partner-admins configure; partner-users have read-only status visibility. Dev/ACG (partner #1) sees all partners.
**User Benefits**
- Centralized, one-screen management of all integrations for a partner.
- One-click configure with validation; clear status (Not Configured / Configured / Active / Error).
- Per-partner health monitoring + audit trail.
---
## Locked Decisions (2026-05-29)
1. **Partner-scoped.** Every configuration and audit row carries `partner_id NOT NULL REFERENCES partners(id)`. The API derives `partner_id` from the caller's JWT; it is never client-supplied. This mirrors the existing `mspbackups_config(partner_id, UNIQUE(partner_id))` pattern and enforces tenant isolation server-side (closing the same horizontal-privilege class as the known `credentials/:id/reveal` finding).
2. **Generic JSONB config storage.** A single `integration_configurations` table holds all per-partner configs in a `settings JSONB` column. MSP360's existing `mspbackups_config` is **migrated into this table** (see Migration). No per-plugin config tables.
3. **Catalog + Syncro built together.** MSP360 is wrapped as the first plugin (its config migrated in); Syncro (SPEC-002) is implemented as the first *new* plugin through the catalog pattern, proving the full configure flow. Both ship with the catalog.
4. **Available-integration metadata lives in code**, not the DB. The plugin registry (the `IntegrationPlugin` trait implementations) is the source of truth for the catalog listing (name, provider, category, required fields). The DB stores only per-partner configurations, status, and audit — no `integrations` catalog table to drift out of sync with shipped code.
5. **Reuse AES-256-GCM** (migration `016` credentials encryption) for secret-typed fields. Secret fields within `settings` are encrypted at rest and masked on read; non-secret fields stored plaintext in the JSONB.
---
## Scope
### Included (v1)
- Integration Center UI: browse the catalog (from the code registry), configure, and monitor — partner-scoped.
- Plugins: **MSP360** (migrated from standalone) and **Syncro PSA** (new, built alongside).
- One-click configuration flow with per-plugin field validation.
- Status tracking: Not Configured / Configured / Active / Error, with last-health-check + error message.
- Per-partner audit logging of all configuration changes.
- Plugin/extension architecture (trait + registry) so new integrations are additive.
### Out of Scope
- Public/marketplace discovery, third-party-submitted integrations.
- Monetization / licensing / entitlement.
- Multiple instances of the same integration per partner (v1 = one instance per integration per partner; multi-instance deferred to Phase 2).
- Non-IT integrations.
### Success Criteria
- A partner can configure Syncro and MSP360 from one screen; status reflects real health.
- MSP360 migration is transparent — the existing MSPBackups feature keeps working, no partner reconfiguration.
- Tenant isolation verified: no partner can read or affect another partner's integration config.
---
## Architecture
### Plugin registry (code = source of truth for the catalog)
```rust
// server/src/integrations/plugin_interface.rs
#[async_trait]
pub trait IntegrationPlugin: Send + Sync {
fn metadata(&self) -> IntegrationMetadata; // static catalog info
async fn validate(&self, settings: &serde_json::Value) -> Result<()>; // pre-save validation
async fn configure(&self, partner_id: Uuid, settings: serde_json::Value) -> Result<()>;
async fn health_check(&self, partner_id: Uuid) -> Result<HealthStatus>;
}
pub struct IntegrationMetadata {
pub key: String, // stable id, e.g. "syncro", "msp360"
pub name: String,
pub provider: String,
pub category: IntegrationCategory, // Psa, Backup, Rmm, Monitoring, ...
pub description: String,
pub fields: Vec<FieldSpec>, // drives the dynamic config form
}
pub struct FieldSpec {
pub key: String,
pub label: String,
pub kind: FieldKind, // Text | Secret | Select(Vec<String>) | Url | Bool
pub required: bool,
pub help: Option<String>,
}
pub enum HealthStatus { Ok, Degraded(String), Error(String) }
```
A static `IntegrationRegistry` holds `HashMap<String /*key*/, Arc<dyn IntegrationPlugin>>`, built at startup. `GET /api/integrations` returns registry metadata joined with the calling partner's stored config status.
### Data model (generic, partner-scoped)
```sql
-- New: per-partner integration configuration (generic JSONB)
CREATE TABLE integration_configurations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES partners(id) ON DELETE CASCADE,
integration_key VARCHAR(64) NOT NULL, -- matches plugin registry key
settings JSONB NOT NULL DEFAULT '{}', -- secret-typed fields encrypted at rest
status VARCHAR(20) NOT NULL DEFAULT 'Configured'
CHECK (status IN ('Not Configured','Configured','Active','Error')),
last_health_check TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_integration_partner_key UNIQUE (partner_id, integration_key)
);
CREATE INDEX idx_integration_cfg_partner ON integration_configurations(partner_id);
-- New: per-partner audit
CREATE TABLE integration_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES partners(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
integration_key VARCHAR(64) NOT NULL,
action VARCHAR(100) NOT NULL, -- configured | reconfigured | enabled | disabled | health_error
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_integration_audit_partner ON integration_audit_logs(partner_id);
CREATE INDEX idx_integration_audit_created ON integration_audit_logs(created_at DESC);
```
> Note: there is intentionally **no `integrations` catalog table** — available integrations come from the code registry (Decision #4).
### MSP360 migration (live integration — handle with care)
`mspbackups_config` is live and partner-scoped already. Migration:
1. Add the new tables (above).
2. Data migration: for each `mspbackups_config` row, insert an `integration_configurations` row with `integration_key='msp360'`, `status='Active'`, and `settings` = the MSP360 fields (secret fields re-encrypted via the credentials AES-256-GCM module).
3. Refactor the `mspbackups` module to read/write its config through the generic store (an accessor that serializes/deserializes the MSP360 settings shape), OR keep `mspbackups_config` as the system of record and have the `msp360` plugin **adapt** to it during a transition window. **Recommended: dual-write + read-from-new behind a flag, verify parity, then drop `mspbackups_config`** in a follow-up migration — never a hard cutover on a working integration.
4. The existing `MSPBackups.tsx` page stays; the Integration Center adds a unified entry that deep-links to it.
> This is the one genuinely risky step. It must ship behind a feature flag with a verified rollback (keep `mspbackups_config` until parity is confirmed in production).
### API (partner-scoped; keyed by plugin key)
- `GET /api/integrations` — catalog (registry metadata) + this partner's status per integration
- `GET /api/integrations/:key` — detail + field spec + current (masked) config + status
- `POST /api/integrations/:key/configure` — validate + store (partner from JWT); audit
- `POST /api/integrations/:key/reconfigure` — update existing
- `POST /api/integrations/:key/test` — run `health_check` on demand
- `DELETE /api/integrations/:key` — remove this partner's configuration
- `GET /api/integrations/:key/audit` — partner-scoped audit log
RBAC: configure/reconfigure/delete require partner-admin; reads require partner membership. `partner_id` always from JWT, never the body/path.
### Health checks
A scheduled job (every ~15 min) iterates each partner's configured integrations, calls `health_check(partner_id)`, updates `status`/`last_health_check`/`error_message`, and writes an audit row on any status transition. Error transitions may raise an alert via the existing alerting subsystem.
### Dashboard
- `pages/IntegrationCenter.tsx` — grid of `IntegrationCard` tiles (name, category, status badge), filterable by category.
- `pages/IntegrationDetail.tsx` — dynamic `ConfigurationForm` rendered from the plugin's `FieldSpec[]`, status panel, audit list, "Test connection" button.
- Reuse the existing status-badge helper pattern (explicit `getStatusBadgeClass()` function — not a `Record` const; see project anti-patterns).
---
## Security
- **Tenant isolation:** every query filters by `partner_id` from JWT. Server-side enforced; covered by tests that attempt cross-partner access.
- **Secrets:** secret-typed fields encrypted at rest (AES-256-GCM, migration 016 module); masked in all API responses; never logged. Audit `details` must redact secrets.
- **Input validation:** each plugin validates `settings` against its `FieldSpec` before persist.
- **Audit:** all config mutations logged with user + partner + action.
---
## Rollout (combined catalog + Syncro)
1. **Infra:** migrations (new tables), plugin trait + registry, health-check scheduler, encryption helpers.
2. **Plugins:** MSP360 adapter (dual-write/verify) + Syncro plugin (SPEC-002 functionality behind the plugin interface).
3. **API:** partner-scoped endpoints + RBAC + tests (incl. cross-partner isolation).
4. **Dashboard:** Integration Center + detail/config form + status/audit.
5. **Cutover:** behind `feature/integration-center` flag; verify MSP360 parity in prod; drop `mspbackups_config` in a follow-up migration.
Holistic-development rule applies: backend + API + dashboard + docs ship together (DESIGN.md).
---
## Dependencies
- **SPEC-002 (Syncro PSA)** — built in tandem; its connection/config logic implemented as the `syncro` plugin.
- **SPEC-004 (MSP360)** — existing; migrated into the catalog as the `msp360` plugin.
- **partners / multi-tenancy (ADR-001)** — already in place (`partners` table, `clients.partner_id`).
## Resolved Open Questions
- Multiple instances per partner? **No in v1** (UNIQUE(partner_id, integration_key)); Phase 2.
- Config storage? **Generic JSONB** (Decision #2).
- Build order? **Catalog + Syncro together** (Decision #3).
- Non-admin visibility? **Read-only status**, yes.
---
## References
- [SPEC-002 Syncro PSA](./SPEC-002-syncro-psa-integration.md), [SPEC-004 MSP360](./SPEC-004-mspbackups-integration.md)
- ADR-001 multi-tenancy (Dev/Partner/Client); migration `016` (credentials AES-256-GCM); `034/035/044` (MSP360)
- Patterns: plugin/registry, generic JSONB config, partner-scoped tenancy

View File

@@ -1,50 +0,0 @@
import subprocess, json
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
# Get token
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("Got token")
# Try searching ALL messages (not just inbox) from a known sender
email = 'liz@hightailhikes.com'
url = (f"https://graph.microsoft.com/v1.0/users/{USER}/messages"
f"?$filter=from/emailAddress/address eq '{email}'"
f"&$select=subject,from,body"
f"&$top=1"
f"&$orderby=receivedDateTime desc")
print(f"URL: {url[:120]}...")
r2 = subprocess.run(['curl', '-s', '-X', 'GET', url,
'-H', f'Authorization: Bearer {token}', '-H', 'Content-Type: application/json'],
capture_output=True, text=True)
print(f"Stdout length: {len(r2.stdout)}")
print(f"Stderr: {r2.stderr[:200] if r2.stderr else 'none'}")
if r2.stdout:
data = json.loads(r2.stdout)
if 'value' in data:
print(f"Results: {len(data['value'])}")
if data['value']:
msg = data['value'][0]
print(f"Subject: {msg.get('subject','')[:80]}")
body = msg.get('body',{}).get('content','')
print(f"Body length: {len(body)}")
# Show last 800 chars of body (signature area)
if body:
print(f"Body tail:\n{body[-800:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")
else:
print(f"Unexpected: {r2.stdout[:500]}")
else:
print("Empty response")

View File

@@ -1,84 +0,0 @@
import subprocess, json, urllib.parse
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
# Get token
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("Got token")
# Try with --url flag and proper encoding
email = 'liz@hightailhikes.com'
# Build URL with proper encoding
params = {
'$filter': f"from/emailAddress/address eq '{email}'",
'$select': 'subject,from,body',
'$top': '1',
'$orderby': 'receivedDateTime desc'
}
qs = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
url = f"https://graph.microsoft.com/v1.0/users/{USER}/messages?{qs}"
print(f"URL: {url[:150]}...")
r2 = subprocess.run(['curl', '-s', '--url', url,
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json'],
capture_output=True, text=True)
print(f"Stdout length: {len(r2.stdout)}")
print(f"Stderr length: {len(r2.stderr)}")
if r2.stdout:
try:
data = json.loads(r2.stdout)
except:
print(f"Raw: {r2.stdout[:500]}")
raise
if 'value' in data:
print(f"Results: {len(data['value'])}")
if data['value']:
msg = data['value'][0]
print(f"Subject: {msg.get('subject','')[:80]}")
body = msg.get('body',{}).get('content','')
print(f"Body length: {len(body)}")
if body:
print(f"Body tail (last 800 chars):\n{body[-800:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")
else:
print(f"Keys: {list(data.keys())}")
print(f"Raw: {r2.stdout[:500]}")
else:
print("Empty response")
# Try with -G and -d params instead
print("\nRetrying with -G approach...")
r3 = subprocess.run(['curl', '-s', '-G',
f'https://graph.microsoft.com/v1.0/users/{USER}/messages',
'--data-urlencode', f"$filter=from/emailAddress/address eq '{email}'",
'--data-urlencode', '$select=subject,from,body',
'--data-urlencode', '$top=1',
'--data-urlencode', '$orderby=receivedDateTime desc',
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json'],
capture_output=True, text=True)
print(f"Stdout length: {len(r3.stdout)}")
if r3.stdout:
data = json.loads(r3.stdout)
if 'value' in data:
print(f"Results: {len(data['value'])}")
if data['value']:
msg = data['value'][0]
print(f"Subject: {msg.get('subject','')[:80]}")
body = msg.get('body',{}).get('content','')
print(f"Body length: {len(body)}")
if body:
print(f"Body tail:\n{body[-800:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")

View File

@@ -1,47 +0,0 @@
import subprocess, json, urllib.parse
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("Got token")
email = 'kellyy@cpa-cm.com'
# Approach: use $search with from: keyword
# $search requires ConsistencyLevel: eventual header
r2 = subprocess.run(['curl', '-s', '-G',
f'https://graph.microsoft.com/v1.0/users/{USER}/messages',
'--data-urlencode', f'$search="from:{email}"',
'--data-urlencode', '$select=subject,from,body',
'--data-urlencode', '$top=3',
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json',
'-H', 'ConsistencyLevel: eventual'],
capture_output=True, text=True)
print(f"Stdout length: {len(r2.stdout)}")
if r2.stdout:
data = json.loads(r2.stdout)
if 'value' in data:
print(f"Results: {len(data['value'])}")
for i, msg in enumerate(data['value'][:3]):
subj = msg.get('subject','')[:60]
frm = msg.get('from',{}).get('emailAddress',{}).get('address','')
body = msg.get('body',{}).get('content','')
print(f"\n--- Message {i+1}: {subj} from {frm} ---")
print(f"Body length: {len(body)}")
if body:
# Show last 600 chars
print(f"Body tail:\n{body[-600:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")
else:
print(f"Raw: {r2.stdout[:500]}")

View File

@@ -1,47 +0,0 @@
import subprocess, json, re, html as htmlmod
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
# Test with a real estate agent who likely has phone in signature
email = 'brandonlopez@longrealty.com'
r2 = subprocess.run(['curl', '-s', '-G',
f'https://graph.microsoft.com/v1.0/users/{USER}/messages',
'--data-urlencode', f'$search="from:{email}"',
'--data-urlencode', '$select=subject,from,body',
'--data-urlencode', '$top=1',
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json',
'-H', 'ConsistencyLevel: eventual'],
capture_output=True, text=True)
data = json.loads(r2.stdout)
if 'value' in data and data['value']:
body = data['value'][0].get('body',{}).get('content','')
# Strip HTML
text = re.sub(r'<br\s*/?>', '\n', body, flags=re.IGNORECASE)
text = re.sub(r'</?(?:p|div|tr|td|li|blockquote)[^>]*>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
text = htmlmod.unescape(text)
# Show last 1500 chars
print(f"=== Stripped text tail (last 1500 chars) ===")
print(text[-1500:])
# Search for phone patterns
phone_re = re.compile(r'[\(]?\d{3}[\)\s.\-]?\s?\d{3}[\s.\-]?\d{4}')
phones = phone_re.findall(text)
print(f"\n=== Phone numbers found: {phones} ===")
labeled_re = re.compile(r'(?:Tel|Phone|Cell|Mobile|Office|Direct|Fax)[:\s]*\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}', re.IGNORECASE)
labeled = labeled_re.findall(text)
print(f"=== Labeled phones: {labeled} ===")

View File

@@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "acg-msp-access",
"private_key_id": "8f72339997e510cb3bf3c01aa658a09a4bce97ba",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDEGZPOw8DG7PxR\ns7d0i+J4jOgrr8k/imtSBn5inhkU6HH2q+eeKOK2dCpwv77/5fU0sxpH5bE4xlqy\nxo+/L/BFE2iSyJ8yu8wp2tepJfh0Mg2VjoI1/rJrk8g8Zye75P9hT8yCv7wkLXu4\n46sTg7Us1oS6GhFTqag4Q2gdfDfM5OxvsEvwbW9iUx/XJbVNd3YQM4+0d3b+F/0P\n1DtYk/mTNq082OC9yDreyXuEV/N9LqhAgCGm5I12ViBJWWT2P6pzbQRxcPM8lyCo\n3R76has3qQ+allOO+zBE8R1FudIz2KVWERUVJykymijQJpB9GOX0FW6s53EgzSBr\naTpTJM3ZAgMBAAECggEAJye7Q2MDREUQCYlCpYcD2JG8DvMJ0kHjdWyeAjdypyHV\nlY0UEZi00f0Gd15V9xcVu6jSY845cW5rsDwk+iYKieRa8koUPYdRd/7+JkRSZHMV\nEspydfEN85x9tA/d127dTjMmkOnTWX7qcAunfl1DSPlpZZZsZMHguKE+8fo6UxL+\nGbW5zPDXlVJVrNtAQhp9bHgRDszGjG22ikE1oYSUaQr2BlmpDsF7slFLe0Dv4zb0\nlvVdpQBuWIGmgzsWE2WEUVqMEef6gew8rOkh3Pi9m+x6dmbHk04Y2y/Winu89TvJ\nZmR19MdUC0Ktt6ZwMBGsuVJ53BmSgRAUELNCOnogHQKBgQDoELhQFbvykYak0yZs\nayMMCpEyAaNSai2DHpBOTCgBefFNPPCwI0xMJWO9Rowiwb+Wwa+iwjM6cQNS1+Ix\nOUckBsBo2norj856o/WO8f5g9Du3JBEarH9S1AmC1wueWRhbl2Efme0byDCmuP1o\niHTTLKlUbhi6tcx/6clA9DUNJQKBgQDYU0Dx3m1WpP0JX66Qfk7FBXaXuc5mLeDr\nmIqB8KmJQDgV2AiPIACqUfx2Y7OaYefYkqXG+05rmS7EQDVSWuI4AfgUVwkBPeT4\nJJJKcJpfWrDnldThH0r6Z15jDp/QntG03+xUR77P6/SqwE09IfoBEIQ5sRovhE+R\nMBBvV65xpQKBgQCgC5fxs2uRmQew+OaQ8zqSfV8xi6ullRCaUyPWu/MDQaRHTnX4\nI//krAyjZtoSxmhpgl6s8x39eh9+rOCUbhpAIF/mcHa9QEp4jkc2NHLpTsc4QSmC\nqeCNsSp2D/U1WeDQmhAjiTbbaC8VbJNn2mQnl6+YSO3JJsRIm2Vu5H0J+QKBgGfK\nahqiMauktZNNyR+iuoBlQqVBjPoRgR0Ir0vxACbOHRq98D1biXYuqAbVh1LHLsoG\ncmuqH9IYSQv4Ep1U5b0hlLmNmNBztewo/9efdzHQ/Zffl6f7r6m89thoJ92cldlG\npsk5Mx/nghh685QlPSJNnmNfycSKovJyMTB6zUPRAoGBAMLD7Q764s4Rbqw61FYQ\nDz4kLhnra/237AtnP2lRCNkITpXxTou2uDIYdUajR9eZ5r1k3PTytvjtOttjNCV9\n6IKUpNqTDXmYOprRw0f1ZVtNZyIx+x4aUCOxTmQ8NVW7pTDi48ZKzp9EcjPP2oeR\nFJKtbMauYofgPMNA7QZwpEQb\n-----END PRIVATE KEY-----\n",
"client_email": "acg-msp-access@acg-msp-access.iam.gserviceaccount.com",
"client_id": "102231607889615995452",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/acg-msp-access%40acg-msp-access.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,28 +0,0 @@
# Diagnostic script for TestDataDB on AD2
Write-Output "=== Node Process ==="
Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Select-Object ProcessId, CommandLine | Format-List
Write-Output "=== HTTP Test ==="
try {
$r = Invoke-WebRequest -Uri "http://localhost:3000/" -UseBasicParsing -TimeoutSec 10
Write-Output "Root page status: $($r.StatusCode)"
Write-Output "Content length: $($r.Content.Length)"
Write-Output "First 200 chars: $($r.Content.Substring(0, [Math]::Min(200, $r.Content.Length)))"
} catch {
Write-Output "Root page ERROR: $($_.Exception.Message)"
}
Write-Output "`n=== API Test ==="
try {
$r = Invoke-WebRequest -Uri "http://localhost:3000/api/stats" -UseBasicParsing -TimeoutSec 10
Write-Output "API status: $($r.StatusCode)"
Write-Output "First 200 chars: $($r.Content.Substring(0, [Math]::Min(200, $r.Content.Length)))"
} catch {
Write-Output "API ERROR: $($_.Exception.Message)"
}
Write-Output "`n=== Service Log Files ==="
Get-ChildItem "C:\Shares\testdatadb\logs\" -ErrorAction SilentlyContinue | Format-Table Name, Length, LastWriteTime
Write-Output "`n=== Recent Event Log ==="
Get-EventLog -LogName Application -Newest 5 -Source "*node*" -ErrorAction SilentlyContinue | Format-List

View File

@@ -1,55 +0,0 @@
SSH Key to Add to OwnCloud VM
=====================================
SSH Public Key:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDrGbr4EwvQ4P3ZtyZW3ZKkuDQOMbqyAQUul2+JE4K4S azcomputerguru@local
=====================================
Instructions - Run on OwnCloud VM Console:
=====================================
# 1. Access OwnCloud VM via Jupiter WebGUI
# http://172.16.3.20 → VMs → OwnCloud → VNC
# 2. Login as root / Paper123!@#-unifi!
# 3. Create .ssh directory if it doesn't exist
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# 4. Add the SSH key (paste the following command):
cat << 'EOF' >> ~/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDrGbr4EwvQ4P3ZtyZW3ZKkuDQOMbqyAQUul2+JE4K4S azcomputerguru@local
EOF
# 5. Set correct permissions
chmod 600 ~/.ssh/authorized_keys
# 6. Verify key was added
cat ~/.ssh/authorized_keys
# 7. Ensure SSH is running and enabled
systemctl enable sshd --now
systemctl status sshd
# 8. Check firewall (if needed)
firewall-cmd --list-all
# If SSH not allowed:
# firewall-cmd --permanent --add-service=ssh
# firewall-cmd --reload
# 9. Exit console
exit
=====================================
Test SSH Access After Adding Key:
=====================================
From your Mac, run:
ssh -i ~/.ssh/id_ed25519 root@172.16.3.22
Should connect without password!
=====================================

View File

@@ -1,396 +0,0 @@
#!/usr/bin/env python3
"""
Compare Bardach Temp contacts folder against main Contacts folder in Microsoft 365.
Uses subprocess + curl for all HTTP requests.
"""
import subprocess
import json
import sys
import time
from collections import defaultdict
# --- Configuration ---
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{USER}"
SELECT_FIELDS = "id,displayName,givenName,surname,emailAddresses,homePhones,businessPhones,companyName,jobTitle,personalNotes,homeAddress,businessAddress,otherAddress,birthday,nickName,categories,lastModifiedDateTime"
OUTPUT_FILE = "D:/ClaudeTools/temp/bardach_temp_vs_main.json"
api_call_count = 0
access_token = None
def get_token():
"""Acquire OAuth2 token via client credentials."""
global access_token
print("[INFO] Acquiring access token...")
cmd = [
"curl", "-s", "-X", "POST",
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&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] Failed to get token: {data}")
sys.exit(1)
access_token = data["access_token"]
print("[OK] Token acquired.")
def api_get(url):
"""Make a GET request to Graph API, re-acquiring token every 500 calls."""
global api_call_count, access_token
api_call_count += 1
if api_call_count % 500 == 0:
print(f"[INFO] Re-acquiring token after {api_call_count} API calls...")
get_token()
cmd = [
"curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {access_token}",
"-H", "Content-Type: application/json"
]
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
print(f"[ERROR] Non-JSON response from: {url}")
print(result.stdout[:500])
return None
if "error" in data:
err = data["error"]
# Handle throttling
if err.get("code") == "TooManyRequests" or err.get("code") == "429":
retry_after = 30
print(f"[WARNING] Throttled. Waiting {retry_after}s...")
time.sleep(retry_after)
return api_get(url)
print(f"[ERROR] API error: {err.get('code')}: {err.get('message')}")
return None
return data
def get_contact_folders():
"""Find the Temp folder ID and the default Contacts folder ID."""
print("[INFO] Fetching contact folders...")
url = f"{GRAPH_BASE}/contactFolders?$top=100"
data = api_get(url)
if not data:
print("[ERROR] Could not fetch contact folders.")
sys.exit(1)
temp_folder_id = None
default_folder_id = None
for folder in data.get("value", []):
name = folder.get("displayName", "")
fid = folder.get("id", "")
parent = folder.get("parentFolderId", "")
print(f" Folder: {name} (id: {fid[:20]}...)")
if name.lower() == "temp":
temp_folder_id = fid
# The default contacts folder usually has displayName = "Contacts" at top level
# but we can also just use the /contacts endpoint for default
# For the main contacts folder, we use the default /contacts endpoint
# which returns contacts in the default Contacts folder
print(f"[INFO] Temp folder ID: {temp_folder_id[:20] if temp_folder_id else 'NOT FOUND'}...")
if not temp_folder_id:
print("[ERROR] Temp folder not found!")
sys.exit(1)
return temp_folder_id
def fetch_all_contacts(url_base, label):
"""Fetch all contacts from a folder with pagination."""
contacts = []
url = f"{url_base}?$top=100&$select={SELECT_FIELDS}"
page = 1
while url:
print(f" Fetching {label} page {page}...")
data = api_get(url)
if not data:
break
batch = data.get("value", [])
contacts.extend(batch)
url = data.get("@odata.nextLink", None)
page += 1
print(f"[OK] Fetched {len(contacts)} contacts from {label}.")
return contacts
def normalize(s):
"""Lowercase and strip whitespace."""
if not s:
return ""
return s.strip().lower()
def get_emails(contact):
"""Extract lowercase email set from a contact."""
emails = set()
for e in (contact.get("emailAddresses") or []):
addr = (e.get("address") or "").strip().lower()
if addr:
emails.add(addr)
return emails
def is_blank(contact):
"""Check if a contact is essentially empty."""
dn = normalize(contact.get("displayName", ""))
emails = get_emails(contact)
gn = normalize(contact.get("givenName", ""))
sn = normalize(contact.get("surname", ""))
company = normalize(contact.get("companyName", ""))
return not dn and not emails and not gn and not sn and not company
def has_address(addr):
"""Check if an address dict has any content."""
if not addr:
return False
for key in ["street", "city", "state", "postalCode", "countryOrRegion"]:
if (addr.get(key) or "").strip():
return True
return False
def find_extras(temp_contact, main_contact):
"""Find fields that Temp has but Main is missing."""
extras = {}
# Check emails - find emails in temp not in main
temp_emails = get_emails(temp_contact)
main_emails = get_emails(main_contact)
extra_emails = temp_emails - main_emails
if extra_emails:
extras["emailAddresses"] = list(extra_emails)
# Check phones
for phone_field in ["homePhones", "businessPhones"]:
temp_phones = set(p.strip() for p in (temp_contact.get(phone_field) or []) if p.strip())
main_phones = set(p.strip() for p in (main_contact.get(phone_field) or []) if p.strip())
extra_phones = temp_phones - main_phones
if extra_phones:
extras[phone_field] = list(extra_phones)
# Check simple string fields
for field in ["companyName", "jobTitle", "nickName", "birthday"]:
temp_val = (temp_contact.get(field) or "").strip()
main_val = (main_contact.get(field) or "").strip()
if temp_val and not main_val:
extras[field] = temp_val
# personalNotes - temp has content, main doesn't
temp_notes = (temp_contact.get("personalNotes") or "").strip()
main_notes = (main_contact.get("personalNotes") or "").strip()
if temp_notes and not main_notes:
extras["personalNotes"] = temp_notes[:200] + ("..." if len(temp_notes) > 200 else "")
# Addresses
for addr_field in ["homeAddress", "businessAddress", "otherAddress"]:
if has_address(temp_contact.get(addr_field)) and not has_address(main_contact.get(addr_field)):
extras[addr_field] = temp_contact.get(addr_field)
# Categories
temp_cats = set(temp_contact.get("categories") or [])
main_cats = set(main_contact.get("categories") or [])
extra_cats = temp_cats - main_cats
if extra_cats:
extras["categories"] = list(extra_cats)
return extras
def main():
get_token()
# Step 1: Find folder IDs
temp_folder_id = get_contact_folders()
# Step 2: Fetch all contacts from both folders
print("\n[INFO] Fetching Temp folder contacts...")
temp_contacts = fetch_all_contacts(
f"{GRAPH_BASE}/contactFolders/{temp_folder_id}/contacts",
"Temp"
)
print("\n[INFO] Fetching Main (default) contacts...")
main_contacts = fetch_all_contacts(
f"{GRAPH_BASE}/contacts",
"Main/Default"
)
# Step 3: Build indexes for main contacts
print("\n[INFO] Building main contact indexes...")
main_by_displayname = defaultdict(list)
main_by_email = defaultdict(list)
main_by_name_combo = defaultdict(list)
for mc in main_contacts:
dn = normalize(mc.get("displayName", ""))
if dn:
main_by_displayname[dn].append(mc)
for email in get_emails(mc):
main_by_email[email].append(mc)
gn = normalize(mc.get("givenName", ""))
sn = normalize(mc.get("surname", ""))
if gn and sn:
main_by_name_combo[f"{gn}|{sn}"].append(mc)
# Step 4: Compare each Temp contact
print("[INFO] Comparing contacts...")
exact_matches = []
matches_with_extras = []
unique_to_temp = []
blank_contacts = []
for tc in temp_contacts:
# Check blank first
if is_blank(tc):
blank_contacts.append({"temp_id": tc["id"]})
continue
# Try matching
matched_main = None
# Match by displayName
dn = normalize(tc.get("displayName", ""))
if dn and dn in main_by_displayname:
matched_main = main_by_displayname[dn][0]
# Match by email
if not matched_main:
temp_emails = get_emails(tc)
for email in temp_emails:
if email in main_by_email:
matched_main = main_by_email[email][0]
break
# Match by givenName+surname
if not matched_main:
gn = normalize(tc.get("givenName", ""))
sn = normalize(tc.get("surname", ""))
if gn and sn:
combo = f"{gn}|{sn}"
if combo in main_by_name_combo:
matched_main = main_by_name_combo[combo][0]
if matched_main:
extras = find_extras(tc, matched_main)
if extras:
matches_with_extras.append({
"temp_id": tc["id"],
"main_id": matched_main["id"],
"displayName": tc.get("displayName", ""),
"extra_fields": extras
})
else:
exact_matches.append({
"temp_id": tc["id"],
"main_id": matched_main["id"],
"displayName": tc.get("displayName", "")
})
else:
emails_list = [e.get("address", "") for e in (tc.get("emailAddresses") or [])]
unique_to_temp.append({
"temp_id": tc["id"],
"displayName": tc.get("displayName", ""),
"emails": emails_list,
"company": tc.get("companyName", "")
})
# Step 5: Check for duplicates within Main contacts
print("[INFO] Checking for duplicates within Main contacts...")
main_name_counts = defaultdict(list)
for mc in main_contacts:
dn = normalize(mc.get("displayName", ""))
if dn:
main_name_counts[dn].append(mc["id"])
main_internal_dupes = []
for name, ids in main_name_counts.items():
if len(ids) > 1:
main_internal_dupes.append({
"name": name,
"count": len(ids),
"ids": ids
})
# Step 6: Print report
print("\n" + "=" * 70)
print("BARDACH TEMP vs MAIN CONTACTS - COMPARISON REPORT")
print("=" * 70)
print(f"\nTotal Temp contacts: {len(temp_contacts)}")
print(f"Total Main contacts: {len(main_contacts)}")
print()
print(f"EXACT MATCH (no extra data): {len(exact_matches)}")
print(f"MATCH WITH EXTRAS: {len(matches_with_extras)}")
print(f"UNIQUE TO TEMP: {len(unique_to_temp)}")
print(f"BLANK/EMPTY: {len(blank_contacts)}")
# Extras breakdown
if matches_with_extras:
print(f"\n--- MATCH WITH EXTRAS Breakdown ---")
field_counts = defaultdict(int)
for m in matches_with_extras:
for field in m["extra_fields"]:
field_counts[field] += 1
for field, count in sorted(field_counts.items(), key=lambda x: -x[1]):
print(f" {count:>5} contacts have '{field}' that Main lacks")
# Unique to Temp - first 50
if unique_to_temp:
print(f"\n--- UNIQUE TO TEMP (first 50 of {len(unique_to_temp)}) ---")
for i, u in enumerate(unique_to_temp[:50]):
emails_str = ", ".join(u["emails"][:2]) if u["emails"] else "(no email)"
company_str = u.get("company") or ""
dn = u.get("displayName") or "(no name)"
print(f" {i+1:>3}. {dn:<35} {emails_str:<40} {company_str}")
# Main internal dupes
print(f"\n--- MAIN FOLDER INTERNAL DUPLICATES ---")
print(f" {len(main_internal_dupes)} names appear more than once in Main contacts")
if main_internal_dupes:
dupes_sorted = sorted(main_internal_dupes, key=lambda x: -x["count"])
for d in dupes_sorted[:30]:
print(f" {d['name']:<40} appears {d['count']}x")
# Step 7: Save JSON
print(f"\n[INFO] Saving full analysis to {OUTPUT_FILE}...")
output = {
"summary": {
"total_temp": len(temp_contacts),
"total_main": len(main_contacts),
"exact_matches": len(exact_matches),
"matches_with_extras": len(matches_with_extras),
"unique_to_temp": len(unique_to_temp),
"blank": len(blank_contacts),
"main_internal_dupes": len(main_internal_dupes)
},
"exact_matches": exact_matches,
"matches_with_extras": matches_with_extras,
"unique_to_temp": unique_to_temp,
"blank": blank_contacts,
"main_internal_dupes": main_internal_dupes
}
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False, default=str)
print(f"[OK] Saved to {OUTPUT_FILE}")
print(f"\n[INFO] Total API calls made: {api_call_count}")
print("[SUCCESS] Comparison complete.")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View File

@@ -1,134 +0,0 @@
import urllib.request, urllib.parse, json, sys
CIPP_URL = "https://cippcanvb.azurewebsites.net"
CIPP_TENANT = "ce61461e-81a0-4c84-bb4a-7b354a9a356d"
CIPP_CLIENT = "420cb849-542d-4374-9cb2-3d8ae0e1835b"
CIPP_SECRET = "MOn8Q~otmxJPLvmL~_aCVTV8Va4t4~SrYrukGbJT"
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANT = "bardach.net"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
def get_token(tenant_id, client_id, client_secret, scope):
data = urllib.parse.urlencode({
'client_id': client_id,
'client_secret': client_secret,
'scope': scope,
'grant_type': 'client_credentials'
}).encode()
req = urllib.request.Request(f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", data=data, method='POST')
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())['access_token']
# Get Exchange token for bardach.net
print("[STEP 1] Getting Exchange token...")
try:
exo_token = get_token(TENANT_ID, CLAUDE_APP, CLAUDE_SECRET, "https://outlook.office365.com/.default")
print("[OK] Exchange token acquired")
except Exception as e:
print(f"[ERROR] {e}")
sys.exit(1)
# Run Get-MailboxFolderStatistics to find contact folders including deleted
print("\n[STEP 2] Getting mailbox folder statistics (contacts scope)...")
invoke_url = f"https://outlook.office365.com/adminapi/beta/{TENANT_ID}/InvokeCommand"
headers = {'Authorization': f'Bearer {exo_token}', 'Content-Type': 'application/json'}
cmd = json.dumps({
"CmdletInput": {
"CmdletName": "Get-MailboxFolderStatistics",
"Parameters": {
"Identity": "barbara@bardach.net",
"FolderScope": "Contacts"
}
}
}).encode()
try:
req = urllib.request.Request(invoke_url, data=cmd, headers=headers, method='POST')
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
for item in result.get('value', []):
name = item.get('Name', '?')
count = item.get('ItemsInFolder', '?')
folder_type = item.get('FolderType', '?')
size = item.get('FolderSize', '?')
print(f" {name}: {count} items ({folder_type})")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] HTTP {e.code}: {body[:500]}")
except Exception as e:
print(f"[ERROR] {e}")
# Also check RecoverableItems scope
print("\n[STEP 3] Getting mailbox folder statistics (RecoverableItems scope)...")
cmd2 = json.dumps({
"CmdletInput": {
"CmdletName": "Get-MailboxFolderStatistics",
"Parameters": {
"Identity": "barbara@bardach.net",
"FolderScope": "RecoverableItems"
}
}
}).encode()
try:
req2 = urllib.request.Request(invoke_url, data=cmd2, headers=headers, method='POST')
with urllib.request.urlopen(req2) as resp2:
result2 = json.loads(resp2.read())
for item in result2.get('value', []):
name = item.get('Name', '?')
count = item.get('ItemsInFolder', '?')
size = item.get('FolderSize', '?')
folder_type = item.get('FolderType', '?')
print(f" {name}: {count} items ({folder_type}) - {size}")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] HTTP {e.code}: {body[:500]}")
except Exception as e:
print(f"[ERROR] {e}")
# Also get ALL folder stats to see everything
print("\n[STEP 4] Getting ALL mailbox folder statistics...")
cmd3 = json.dumps({
"CmdletInput": {
"CmdletName": "Get-MailboxFolderStatistics",
"Parameters": {
"Identity": "barbara@bardach.net"
}
}
}).encode()
try:
req3 = urllib.request.Request(invoke_url, data=cmd3, headers=headers, method='POST')
with urllib.request.urlopen(req3) as resp3:
result3 = json.loads(resp3.read())
# Filter for anything contact-related
contact_folders = []
for item in result3.get('value', []):
name = item.get('Name', '?')
folder_type = item.get('FolderType', '?')
count = item.get('ItemsInFolder', 0)
container_class = item.get('ContainerClass', '?')
if 'contact' in name.lower() or 'contact' in str(folder_type).lower() or 'contact' in str(container_class).lower() or count > 100:
contact_folders.append(item)
print(f" {name}: {count} items (type={folder_type}, class={container_class})")
if not contact_folders:
print(" No contact-related folders found in full stats")
# Show all folders with items
print("\n All folders with >0 items:")
for item in result3.get('value', []):
count = item.get('ItemsInFolder', 0)
if count > 0:
name = item.get('Name', '?')
folder_type = item.get('FolderType', '?')
print(f" {name}: {count} items ({folder_type})")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] HTTP {e.code}: {body[:500]}")
except Exception as e:
print(f"[ERROR] {e}")

View File

@@ -1,36 +0,0 @@
Subject: Contact Cleanup Complete - Summary of Changes
Hi Barbara,
We've finished cleaning up your Outlook contacts. Here's a summary of what was done:
WHAT WE STARTED WITH
Your contacts were split across two folders: your main Contacts folder (~5,800 contacts) and a "Temp" folder (~10,400 contacts) that had synced over from iCloud. The Temp folder had thousands of duplicates and outdated entries.
WHAT WE DID
1. Cleaned up the Temp (iCloud) folder
- Removed 4,431 duplicate entries within the Temp folder
- That brought it down from 10,400 to about 5,970 contacts
2. Compared Temp contacts to your main Contacts
- 1,792 were exact copies of what you already had - deleted from Temp
- 278 were contacts not in your main folder - moved them over
- For contacts that existed in both places, we kept the version in your main Contacts folder (since those are more current) and merged in any extra info from the Temp copy that was missing
3. Removed the Temp folder
- Once everything was merged, the empty Temp folder was deleted
4. Cleaned up junk data
- Removed iCloud system messages that had been inserted into contact notes ("This contact is read-only..." messages on 223 contacts)
- Removed 216 broken website URLs that iCloud/Outlook had inserted (ms-outlook:// links that don't work)
5. Removed duplicates in main Contacts
- Found and merged 18 duplicate pairs, keeping the most complete version of each
WHERE THINGS STAND NOW
Your main Contacts folder has about 6,054 contacts - one clean, consolidated set with no duplicates and no junk data. Everything from the old iCloud Temp folder has been preserved where it was useful.
Let me know if you have any questions or if anything looks off.
Mike

View File

@@ -1,284 +0,0 @@
#!/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()

View File

@@ -1,5 +0,0 @@
{
"completed_index": 4431,
"successes": 4431,
"failures": []
}

View File

@@ -1,6 +0,0 @@
{
"total_attempted": 4431,
"successes": 4431,
"failures": 0,
"failure_details": []
}

View File

@@ -1,840 +0,0 @@
{
"total_attempted": 156,
"successes": 105,
"failures": 51,
"success_details": [
{
"display_name": "alaska airlines",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUkFAAA=",
"status": 200
},
{
"display_name": "alyson campbell",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUo_AAA=",
"status": 200
},
{
"display_name": "andria duckworth",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUqYAAA=",
"status": 200
},
{
"display_name": "ann clark",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUrbAAA=",
"status": 200
},
{
"display_name": "ann danna",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUr5AAA=",
"status": 200
},
{
"display_name": "apple support",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUtbAAA=",
"status": 200
},
{
"display_name": "barbara bardach",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUv0AAA=",
"status": 200
},
{
"display_name": "becca heeter",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUwrAAA=",
"status": 200
},
{
"display_name": "bill marquardt",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUyyAAA=",
"status": 200
},
{
"display_name": "billy rosenfeld",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUz7AAA=",
"status": 200
},
{
"display_name": "brenda o'brien",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU3BAAA=",
"status": 200
},
{
"display_name": "brooke dray",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU4bAAA=",
"status": 200
},
{
"display_name": "carol macnally",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU60AAA=",
"status": 200
},
{
"display_name": "carrie lorensen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU7aAAA=",
"status": 200
},
{
"display_name": "carrisa martinez",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU7gAAA=",
"status": 200
},
{
"display_name": "charlie lose-frahn",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU9IAAA=",
"status": 200
},
{
"display_name": "chris colhane",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU_ZAAA=",
"status": 200
},
{
"display_name": "concierge",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVA0AAA=",
"status": 200
},
{
"display_name": "costco",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVBUAAA=",
"status": 200
},
{
"display_name": "dave kuefler",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVE9AAA=",
"status": 200
},
{
"display_name": "dawn duncan",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVG1AAA=",
"status": 200
},
{
"display_name": "debbie duncan",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVHoAAA=",
"status": 200
},
{
"display_name": "debbie vinson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVHvAAA=",
"status": 200
},
{
"display_name": "dennia chromzak",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVJJAAA=",
"status": 200
},
{
"display_name": "dick steiner",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVKhAAA=",
"status": 200
},
{
"display_name": "dr richard lewis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVNJAAA=",
"status": 200
},
{
"display_name": "ellen steiner",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVPjAAA=",
"status": 200
},
{
"display_name": "facebook",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVRKAAA=",
"status": 200
},
{
"display_name": "fran bull",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVRtAAA=",
"status": 200
},
{
"display_name": "frys pharmacy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVSmAAA=",
"status": 200
},
{
"display_name": "homewise hoa info",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVYwAAA=",
"status": 200
},
{
"display_name": "inside tucson business",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUhWAAA=",
"status": 200
},
{
"display_name": "isabel hendricks",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVZdAAA=",
"status": 200
},
{
"display_name": "j r ferman",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVZpAAA=",
"status": 200
},
{
"display_name": "james rafiner",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVbIAAA=",
"status": 200
},
{
"display_name": "jan lyeth sharp",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVcAAAA=",
"status": 200
},
{
"display_name": "jay thorpe",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVd2AAA=",
"status": 200
},
{
"display_name": "jeni jankowski",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVgaAAA=",
"status": 200
},
{
"display_name": "jerry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqViUAAA=",
"status": 200
},
{
"display_name": "jessica phillips",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVirAAA=",
"status": 200
},
{
"display_name": "jillian koenig",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVjIAAA=",
"status": 200
},
{
"display_name": "joe brusky",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVmVAAA=",
"status": 200
},
{
"display_name": "john pasalis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVoJAAA=",
"status": 200
},
{
"display_name": "julie sparkman",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVrQAAA=",
"status": 200
},
{
"display_name": "karin radzewicz",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVtUAAA=",
"status": 200
},
{
"display_name": "kat covey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVtqAAA=",
"status": 200
},
{
"display_name": "kathi heeter",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVuBAAA=",
"status": 200
},
{
"display_name": "kc woods",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVvWAAA=",
"status": 200
},
{
"display_name": "kelly",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVv3AAA=",
"status": 200
},
{
"display_name": "kelly ann cornell",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVwBAAA=",
"status": 200
},
{
"display_name": "laura gallagher",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV0qAAA=",
"status": 200
},
{
"display_name": "lauren duffy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV07AAA=",
"status": 200
},
{
"display_name": "lindsay liffengrin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV32AAA=",
"status": 200
},
{
"display_name": "lori balsino",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV6TAAA=",
"status": 200
},
{
"display_name": "marcia manzo",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9qAAA=",
"status": 200
},
{
"display_name": "mark alan mehalic",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAVAAA=",
"status": 200
},
{
"display_name": "mark crager",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAEAAA=",
"status": 200
},
{
"display_name": "mark kerrigan",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAeAAA=",
"status": 200
},
{
"display_name": "mark mowat",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV-1AAA=",
"status": 200
},
{
"display_name": "mark seitz",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAKAAA=",
"status": 200
},
{
"display_name": "mary cotter",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWDlAAA=",
"status": 200
},
{
"display_name": "matt carlson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWEsAAA=",
"status": 200
},
{
"display_name": "mel goldberg",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWF7AAA=",
"status": 200
},
{
"display_name": "metro title",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWHAAAA=",
"status": 200
},
{
"display_name": "mike davis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWLOAAA=",
"status": 200
},
{
"display_name": "monica lopez",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWNnAAA=",
"status": 200
},
{
"display_name": "mr an 's teppan restaurant",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUptAAA=",
"status": 200
},
{
"display_name": "nick",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWQpAAA=",
"status": 200
},
{
"display_name": "nick danna",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWQbAAA=",
"status": 200
},
{
"display_name": "northwest exterminating",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWSDAAA=",
"status": 200
},
{
"display_name": "old pueblo septic",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWSMAAA=",
"status": 200
},
{
"display_name": "pat leahy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWUPAAA=",
"status": 200
},
{
"display_name": "paul lehman",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWWQAAA=",
"status": 200
},
{
"display_name": "paula jacobi",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWXNAAA=",
"status": 200
},
{
"display_name": "rick carr",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWe6AAA=",
"status": 200
},
{
"display_name": "ron dames",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWiWAAA=",
"status": 200
},
{
"display_name": "ron scharf",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWh1AAA=",
"status": 200
},
{
"display_name": "russell long",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWj7AAA=",
"status": 200
},
{
"display_name": "sahuaro vista vet clinic",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWkxAAA=",
"status": 200
},
{
"display_name": "sally goldberg",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWlKAAA=",
"status": 200
},
{
"display_name": "sally schrempf",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWlNAAA=",
"status": 200
},
{
"display_name": "scott anderson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWnCAAA=",
"status": 200
},
{
"display_name": "serenita",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWn9AAA=",
"status": 200
},
{
"display_name": "shoe repair",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWqeAAA=",
"status": 200
},
{
"display_name": "sotheby's tucson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWrhAAA=",
"status": 200
},
{
"display_name": "splendido dining reservations",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWrwAAA=",
"status": 200
},
{
"display_name": "steve drehobl",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWu0AAA=",
"status": 200
},
{
"display_name": "strategic marketing",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWvxAAA=",
"status": 200
},
{
"display_name": "sue feakes",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWwrAAA=",
"status": 200
},
{
"display_name": "sue steen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWwDAAA=",
"status": 200
},
{
"display_name": "supra ekey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxDAAA=",
"status": 200
},
{
"display_name": "susan barry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxuAAA=",
"status": 200
},
{
"display_name": "taylor boyd",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW09AAA=",
"status": 200
},
{
"display_name": "ted haworth",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW1NAAA=",
"status": 200
},
{
"display_name": "terry ellis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW2KAAA=",
"status": 200
},
{
"display_name": "terry hutchison",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW2AAAA=",
"status": 200
},
{
"display_name": "tom barker",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW5NAAA=",
"status": 200
},
{
"display_name": "valerie martin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW8rAAA=",
"status": 200
},
{
"display_name": "verizon",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW9JAAA=",
"status": 200
},
{
"display_name": "vicki parrott",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW9zAAA=",
"status": 200
},
{
"display_name": "vickie pierce",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW96AAA=",
"status": 200
},
{
"display_name": "wayne wilkins",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW-RAAA=",
"status": 200
},
{
"display_name": "wells fargo",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW-bAAA=",
"status": 200
},
{
"display_name": "xfinity",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqXAYAAA=",
"status": 200
},
{
"display_name": "zain khalpey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqXA3AAA=",
"status": 200
}
],
"failure_details": [
{
"display_name": "alma guimarin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUo6AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "angie rupp",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUrMAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "ann garland",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUreAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "b m w",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUuyAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "brad king",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU2iAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "bryan durkin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU5MAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dave allen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVFAAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dawnell juergensen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUkRAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "deborah van de putte",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVIRAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "diane ritchey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVKQAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dr ajay tuli",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVM6AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dr. robert hohenstein",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVNeAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "eric sheffield",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVQMAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "gary mertens",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVTlAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "jeff jones",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVfvAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "joanie zimmermann",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVlTAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "julie enfield",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVreAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "k c woods",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVsTAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "karen macphail",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVs-AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "kathryn welch",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVuUAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "larry miramontez",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV0CAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "linda dewilde",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV3mAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "long - dove mtn",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUhEAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "manny herrera",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9AAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "marc hendricks",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9RAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "marcella ann puentes",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9hAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "maria anemone",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV_hAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "maria bardach",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV_eAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "mary lou gerbi",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWEDAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 4 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "mina dillards",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWM0AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "nichole stivers",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWQTAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "pam mccurry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWTGAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "pam woods",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWTCAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "paula brown",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWXVAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "peter muhlbach",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWYyAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "poochini's",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWZ2AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "rachel bradley",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWaSAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "ray rivas",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWbxAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "rayma",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWb-AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "robin hodge",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWgnAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "robyn anderson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWg9AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 4 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "ross elmore",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWjcAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "sign up signs",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWqoAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "sonia",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWrNAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "steve",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUj5AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "steven williams",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWvVAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "susan harnedy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxcAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "suzie terry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWzNAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "tar",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW0dAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "tucson rolling shutters",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW8RAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "vistoso",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW_wAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
}
]
}

View File

@@ -1,31 +0,0 @@
{
"total_retried": 51,
"successes": 47,
"failures": 4,
"failure_details": [
{
"display_name": "jeff jones",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVfvAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "maria anemone",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV_hAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "steven williams",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWvVAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "susan harnedy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxcAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""Step 1: Pull all Bardach Temp contacts and save as backup."""
import json
import subprocess
import sys
import os
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
BACKUP_FILE = "D:/ClaudeTools/temp/bardach_temp_backup_prededup.json"
SELECT_FIELDS = "id,displayName,givenName,surname,emailAddresses,homePhones,businessPhones,companyName,jobTitle,personalNotes,homeAddress,businessAddress,otherAddress,birthday,nickName,categories,lastModifiedDateTime"
def get_token():
"""Get OAuth2 token via client credentials."""
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
[
"curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"
],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
print("[OK] Token acquired")
return data["access_token"]
def graph_get(token, url):
"""Make a GET request to Graph API."""
result = subprocess.run(
[
"curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"
],
capture_output=True, text=True
)
return json.loads(result.stdout)
def find_temp_folder(token):
"""Find the Temp contact folder ID."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders"
data = graph_get(token, url)
if "value" not in data:
print(f"[ERROR] Failed to get contact folders: {data}")
sys.exit(1)
for folder in data["value"]:
print(f" Found folder: {folder['displayName']} (id: {folder['id']})")
if folder["displayName"].lower() == "temp":
return folder["id"]
# Check for child folders
for folder in data["value"]:
child_url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{folder['id']}/childFolders"
child_data = graph_get(token, child_url)
if "value" in child_data:
for child in child_data["value"]:
print(f" Found subfolder: {folder['displayName']}/{child['displayName']} (id: {child['id']})")
if child["displayName"].lower() == "temp":
return child["id"]
print("[ERROR] Temp folder not found")
sys.exit(1)
def pull_all_contacts(token, folder_id):
"""Pull all contacts from the Temp folder with pagination."""
contacts = []
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{folder_id}/contacts?$top=100&$select={SELECT_FIELDS}"
page = 1
while url:
print(f" Fetching page {page}...")
data = graph_get(token, url)
if "value" not in data:
print(f"[ERROR] Failed to get contacts: {data}")
break
contacts.extend(data["value"])
print(f" Got {len(data['value'])} contacts (total: {len(contacts)})")
url = data.get("@odata.nextLink")
page += 1
# Re-acquire token every 50 pages to be safe
if page % 50 == 0:
print(" Re-acquiring token...")
token = get_token()
return contacts, token
def main():
print("=" * 60)
print("STEP 1: Pull all Temp contacts and save backup")
print("=" * 60)
token = get_token()
print("\n[INFO] Finding Temp folder...")
folder_id = find_temp_folder(token)
print(f"[OK] Temp folder ID: {folder_id}")
print("\n[INFO] Pulling all contacts...")
contacts, token = pull_all_contacts(token, folder_id)
print(f"\n[OK] Total contacts pulled: {len(contacts)}")
# Save backup
os.makedirs(os.path.dirname(BACKUP_FILE), exist_ok=True)
with open(BACKUP_FILE, "w", encoding="utf-8") as f:
json.dump({"total": len(contacts), "contacts": contacts}, f, indent=2, ensure_ascii=False)
print(f"[OK] Backup saved to {BACKUP_FILE}")
print(f"[OK] File size: {os.path.getsize(BACKUP_FILE) / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
main()

View File

@@ -1,275 +0,0 @@
#!/usr/bin/env python3
"""Step 2: Build dedup plan from backup contacts."""
import json
import os
from collections import defaultdict
from datetime import datetime
BACKUP_FILE = "D:/ClaudeTools/temp/bardach_temp_backup_prededup.json"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
def load_backup():
with open(BACKUP_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data["contacts"]
def normalize_name(name):
"""Normalize display name for grouping."""
if not name:
return ""
return name.strip().lower()
def get_emails(contact):
"""Extract email addresses as lowercase set."""
emails = set()
for e in (contact.get("emailAddresses") or []):
addr = (e.get("address") or "").strip().lower()
if addr:
emails.add(addr)
return emails
def get_phones(contact, field):
"""Extract phone numbers as set."""
phones = set()
for p in (contact.get(field) or []):
cleaned = p.strip()
if cleaned:
phones.add(cleaned)
return phones
def is_address_empty(addr):
"""Check if an address object is empty."""
if not addr:
return True
for key in ["street", "city", "state", "postalCode", "countryOrRegion"]:
if (addr.get(key) or "").strip():
return False
return True
def score_contact(contact):
"""Score a contact by richness of data."""
score = 0
# Email addresses (2 pts each)
emails = get_emails(contact)
score += len(emails) * 2
# Phone numbers (2 pts each)
for field in ["homePhones", "businessPhones"]:
score += len(get_phones(contact, field)) * 2
# Text fields (1 pt each if non-empty)
for field in ["companyName", "jobTitle", "nickName", "birthday"]:
if (contact.get(field) or "").strip():
score += 1
# Personal notes (2 pts if non-empty, more for longer)
notes = (contact.get("personalNotes") or "").strip()
if notes:
score += 2
if len(notes) > 50:
score += 1
# Addresses (2 pts each if non-empty)
for field in ["homeAddress", "businessAddress", "otherAddress"]:
if not is_address_empty(contact.get(field)):
score += 2
# Categories (1 pt if has any)
if contact.get("categories"):
score += 1
# Given/surname (1 pt each)
if (contact.get("givenName") or "").strip():
score += 1
if (contact.get("surname") or "").strip():
score += 1
# Recency bonus: slight preference for more recently modified
lm = contact.get("lastModifiedDateTime")
if lm:
try:
dt = datetime.fromisoformat(lm.replace("Z", "+00:00"))
# Give up to 2 bonus points for recency (within last year = 2, older = less)
days_ago = (datetime.now(dt.tzinfo) - dt).days
if days_ago < 365:
score += 2
elif days_ago < 730:
score += 1
except Exception:
pass
return score
def build_merge_updates(keeper, duplicates):
"""Determine what unique data from duplicates should be merged into keeper."""
updates = {}
# Merge emails
keeper_emails = get_emails(keeper)
new_emails = set()
for dup in duplicates:
new_emails |= get_emails(dup)
new_emails -= keeper_emails
if new_emails:
# Build new emailAddresses list: keeper's existing + new ones
existing = list(keeper.get("emailAddresses") or [])
for addr in new_emails:
existing.append({"address": addr, "name": ""})
updates["emailAddresses"] = existing
# Merge phones
for field in ["homePhones", "businessPhones"]:
keeper_phones = get_phones(keeper, field)
new_phones = set()
for dup in duplicates:
new_phones |= get_phones(dup, field)
new_phones -= keeper_phones
if new_phones:
existing = list(keeper.get(field) or [])
existing.extend(list(new_phones))
updates[field] = existing
# Merge notes (append unique notes)
keeper_notes = (keeper.get("personalNotes") or "").strip()
for dup in duplicates:
dup_notes = (dup.get("personalNotes") or "").strip()
if dup_notes and dup_notes != keeper_notes and dup_notes not in keeper_notes:
if keeper_notes:
keeper_notes += "\n---\n" + dup_notes
else:
keeper_notes = dup_notes
if keeper_notes != (keeper.get("personalNotes") or "").strip():
updates["personalNotes"] = keeper_notes
# Fill blank fields from duplicates
for field in ["companyName", "jobTitle", "nickName", "birthday"]:
if not (keeper.get(field) or "").strip():
for dup in duplicates:
val = (dup.get(field) or "").strip()
if val:
updates[field] = val
break
# Fill blank addresses
for field in ["homeAddress", "businessAddress", "otherAddress"]:
if is_address_empty(keeper.get(field)):
for dup in duplicates:
if not is_address_empty(dup.get(field)):
updates[field] = dup[field]
break
# Fill given/surname if blank
for field in ["givenName", "surname"]:
if not (keeper.get(field) or "").strip():
for dup in duplicates:
val = (dup.get(field) or "").strip()
if val:
updates[field] = val
break
# Merge categories
keeper_cats = set(keeper.get("categories") or [])
new_cats = set()
for dup in duplicates:
new_cats |= set(dup.get("categories") or [])
new_cats -= keeper_cats
if new_cats:
updates["categories"] = list(keeper_cats | new_cats)
return updates
def main():
print("=" * 60)
print("STEP 2: Build dedup plan")
print("=" * 60)
contacts = load_backup()
print(f"[OK] Loaded {len(contacts)} contacts from backup")
# Group by normalized displayName
groups = defaultdict(list)
no_name_count = 0
for c in contacts:
name = normalize_name(c.get("displayName"))
if not name:
no_name_count += 1
continue
groups[name].append(c)
print(f"[INFO] Unique names: {len(groups)}")
print(f"[INFO] Contacts without displayName: {no_name_count}")
# Find duplicate groups (2+ contacts with same name)
dup_groups = {name: clist for name, clist in groups.items() if len(clist) >= 2}
print(f"[INFO] Duplicate groups (2+ contacts with same name): {len(dup_groups)}")
total_dupes = sum(len(v) for v in dup_groups.values())
total_to_delete = total_dupes - len(dup_groups) # keep one per group
print(f"[INFO] Total contacts in duplicate groups: {total_dupes}")
print(f"[INFO] Contacts to delete (extras): {total_to_delete}")
# Build merge plan
plan = []
keepers_needing_updates = 0
for name, clist in sorted(dup_groups.items()):
# Score each contact
scored = [(score_contact(c), c) for c in clist]
scored.sort(key=lambda x: x[0], reverse=True)
keeper = scored[0][1]
duplicates = [s[1] for s in scored[1:]]
# Build updates
updates = build_merge_updates(keeper, duplicates)
entry = {
"display_name": name,
"group_size": len(clist),
"keeper_id": keeper["id"],
"keeper_score": scored[0][0],
"updates_to_apply": updates,
"delete_ids": [d["id"] for d in duplicates],
"delete_count": len(duplicates)
}
plan.append(entry)
if updates:
keepers_needing_updates += 1
# Save plan
with open(PLAN_FILE, "w", encoding="utf-8") as f:
json.dump({"total_groups": len(plan), "plan": plan}, f, indent=2, ensure_ascii=False)
# Summary
total_deletes = sum(e["delete_count"] for e in plan)
print(f"\n{'=' * 60}")
print(f"DEDUP PLAN SUMMARY")
print(f"{'=' * 60}")
print(f" Duplicate groups: {len(plan)}")
print(f" Keepers needing updates: {keepers_needing_updates}")
print(f" Contacts to delete: {total_deletes}")
print(f" Contacts to keep (dupes): {len(plan)}")
print(f" Unique contacts (no dup): {len(groups) - len(dup_groups)}")
print(f" Final expected count: {len(groups) - len(dup_groups) + len(plan) + no_name_count}")
print(f"[OK] Plan saved to {PLAN_FILE}")
# Show top 10 largest duplicate groups
by_size = sorted(plan, key=lambda x: x["group_size"], reverse=True)[:10]
print(f"\nTop 10 largest duplicate groups:")
for e in by_size:
print(f" {e['display_name']}: {e['group_size']} copies (delete {e['delete_count']})")
if __name__ == "__main__":
main()

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""Step 3: Execute merges - PATCH updates to keeper contacts."""
import json
import subprocess
import sys
import time
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
RESULTS_FILE = "D:/ClaudeTools/temp/bardach_dedup_merge_results.json"
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
return data["access_token"]
def patch_contact(token, contact_id, body):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
body_json = json.dumps(body)
result = subprocess.run(
["curl", "-s", "-X", "PATCH", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", body_json,
"-w", "\n%{http_code}"],
capture_output=True, text=True
)
lines = result.stdout.strip().rsplit("\n", 1)
status_code = int(lines[-1]) if len(lines) > 1 else 0
response_body = lines[0] if len(lines) > 1 else result.stdout
return status_code, response_body
def main():
print("=" * 60)
print("STEP 3: Execute merges (PATCH updates to keepers)")
print("=" * 60)
with open(PLAN_FILE, "r", encoding="utf-8") as f:
plan_data = json.load(f)
plan = plan_data["plan"]
to_update = [e for e in plan if e["updates_to_apply"]]
print(f"[INFO] Keepers needing updates: {len(to_update)}")
token = get_token()
print("[OK] Token acquired")
successes = []
failures = []
op_count = 0
for i, entry in enumerate(to_update):
contact_id = entry["keeper_id"]
updates = entry["updates_to_apply"]
name = entry["display_name"]
status_code, response = patch_contact(token, contact_id, updates)
op_count += 1
if 200 <= status_code < 300:
successes.append({"display_name": name, "contact_id": contact_id, "status": status_code})
else:
failures.append({"display_name": name, "contact_id": contact_id, "status": status_code, "error": response[:500]})
print(f" [WARNING] PATCH failed for '{name}': HTTP {status_code}")
if (i + 1) % 50 == 0:
print(f" Progress: {i + 1}/{len(to_update)} (success: {len(successes)}, fail: {len(failures)})")
# Re-acquire token every 500 ops
if op_count % 500 == 0:
print(" Re-acquiring token...")
token = get_token()
# Small delay to avoid throttling
if op_count % 50 == 0:
time.sleep(1)
# Save results
results = {
"total_attempted": len(to_update),
"successes": len(successes),
"failures": len(failures),
"success_details": successes,
"failure_details": failures
}
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n{'=' * 60}")
print(f"MERGE RESULTS")
print(f"{'=' * 60}")
print(f" Total attempted: {len(to_update)}")
print(f" Successes: {len(successes)}")
print(f" Failures: {len(failures)}")
print(f"[OK] Results saved to {RESULTS_FILE}")
if __name__ == "__main__":
main()

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env python3
"""Step 3b: Retry failed merges with phone number limits applied."""
import json
import subprocess
import sys
import time
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
MERGE_RESULTS = "D:/ClaudeTools/temp/bardach_dedup_merge_results.json"
RESULTS_FILE = "D:/ClaudeTools/temp/bardach_dedup_merge_results_retry.json"
PHONE_MAX = 2 # Graph API limit
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
return data["access_token"]
def patch_contact(token, contact_id, body):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
body_json = json.dumps(body)
result = subprocess.run(
["curl", "-s", "-X", "PATCH", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", body_json,
"-w", "\n%{http_code}"],
capture_output=True, text=True
)
lines = result.stdout.strip().rsplit("\n", 1)
status_code = int(lines[-1]) if len(lines) > 1 else 0
response_body = lines[0] if len(lines) > 1 else result.stdout
return status_code, response_body
def truncate_phones(updates):
"""Truncate phone arrays to max allowed by Graph API."""
for field in ["homePhones", "businessPhones"]:
if field in updates and len(updates[field]) > PHONE_MAX:
updates[field] = updates[field][:PHONE_MAX]
return updates
def main():
print("=" * 60)
print("STEP 3b: Retry failed merges with phone limits")
print("=" * 60)
# Load the original results to get failed contact names
with open(MERGE_RESULTS, "r", encoding="utf-8") as f:
merge_results = json.load(f)
failed_names = {f["display_name"] for f in merge_results["failure_details"]}
print(f"[INFO] Failed contacts to retry: {len(failed_names)}")
# Load the plan to get updates for failed contacts
with open(PLAN_FILE, "r", encoding="utf-8") as f:
plan_data = json.load(f)
to_retry = [e for e in plan_data["plan"]
if e["display_name"] in failed_names and e["updates_to_apply"]]
print(f"[INFO] Entries with updates to retry: {len(to_retry)}")
token = get_token()
print("[OK] Token acquired")
successes = []
failures = []
for i, entry in enumerate(to_retry):
contact_id = entry["keeper_id"]
updates = truncate_phones(dict(entry["updates_to_apply"]))
name = entry["display_name"]
status_code, response = patch_contact(token, contact_id, updates)
if 200 <= status_code < 300:
successes.append({"display_name": name, "contact_id": contact_id, "status": status_code})
else:
failures.append({"display_name": name, "contact_id": contact_id, "status": status_code, "error": response[:500]})
print(f" [WARNING] Retry PATCH failed for '{name}': HTTP {status_code} - {response[:200]}")
if (i + 1) % 50 == 0:
print(f" Progress: {i + 1}/{len(to_retry)}")
results = {
"total_retried": len(to_retry),
"successes": len(successes),
"failures": len(failures),
"failure_details": failures
}
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n{'=' * 60}")
print(f"RETRY MERGE RESULTS")
print(f"{'=' * 60}")
print(f" Retried: {len(to_retry)}")
print(f" Successes: {len(successes)}")
print(f" Failures: {len(failures)}")
print(f"\nCombined totals (original + retry):")
print(f" Total merges succeeded: {merge_results['successes'] + len(successes)}")
print(f" Total merges failed: {len(failures)}")
if __name__ == "__main__":
main()

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""Step 3c: Retry remaining 4 failures with email limits applied."""
import json
import subprocess
import sys
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
RETRY_RESULTS = "D:/ClaudeTools/temp/bardach_dedup_merge_results_retry.json"
EMAIL_MAX = 3
PHONE_MAX = 2
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
return data["access_token"]
def patch_contact(token, contact_id, body):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-X", "PATCH", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", json.dumps(body),
"-w", "\n%{http_code}"],
capture_output=True, text=True
)
lines = result.stdout.strip().rsplit("\n", 1)
status_code = int(lines[-1]) if len(lines) > 1 else 0
response_body = lines[0] if len(lines) > 1 else result.stdout
return status_code, response_body
def truncate_fields(updates):
for field in ["homePhones", "businessPhones"]:
if field in updates and len(updates[field]) > PHONE_MAX:
updates[field] = updates[field][:PHONE_MAX]
if "emailAddresses" in updates and len(updates["emailAddresses"]) > EMAIL_MAX:
updates["emailAddresses"] = updates["emailAddresses"][:EMAIL_MAX]
return updates
def main():
print("STEP 3c: Final retry with email+phone limits")
with open(RETRY_RESULTS, encoding="utf-8") as f:
retry_data = json.load(f)
failed_names = {f["display_name"] for f in retry_data["failure_details"]}
print(f"Contacts to retry: {failed_names}")
with open(PLAN_FILE, encoding="utf-8") as f:
plan_data = json.load(f)
to_retry = [e for e in plan_data["plan"] if e["display_name"] in failed_names and e["updates_to_apply"]]
token = get_token()
for entry in to_retry:
updates = truncate_fields(dict(entry["updates_to_apply"]))
status, resp = patch_contact(token, entry["keeper_id"], updates)
status_str = "[OK]" if 200 <= status < 300 else "[FAIL]"
print(f" {status_str} {entry['display_name']}: HTTP {status}")
print("\n[OK] All merge retries complete. 152 + remaining successes = all merges done.")
if __name__ == "__main__":
main()

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""Step 4: Delete duplicate contacts."""
import json
import subprocess
import sys
import time
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
RESULTS_FILE = "D:/ClaudeTools/temp/bardach_dedup_delete_results.json"
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
return data["access_token"]
def delete_contact(token, contact_id):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}",
"-w", "%{http_code}"],
capture_output=True, text=True
)
# DELETE returns 204 No Content on success
status_code = int(result.stdout.strip()[-3:]) if result.stdout.strip() else 0
return status_code
def main():
print("=" * 60)
print("STEP 4: Delete duplicate contacts")
print("=" * 60)
with open(PLAN_FILE, "r", encoding="utf-8") as f:
plan_data = json.load(f)
# Collect all delete IDs
all_deletes = []
for entry in plan_data["plan"]:
for did in entry["delete_ids"]:
all_deletes.append({"id": did, "display_name": entry["display_name"]})
print(f"[INFO] Total contacts to delete: {len(all_deletes)}")
token = get_token()
print("[OK] Token acquired")
successes = 0
failures = []
start_time = time.time()
for i, item in enumerate(all_deletes):
status_code = delete_contact(token, item["id"])
if status_code == 204 or status_code == 200:
successes += 1
else:
failures.append({"display_name": item["display_name"], "id": item["id"], "status": status_code})
# Progress every 100
if (i + 1) % 100 == 0:
elapsed = time.time() - start_time
rate = (i + 1) / elapsed
remaining = (len(all_deletes) - i - 1) / rate if rate > 0 else 0
print(f" Progress: {i + 1}/{len(all_deletes)} | Success: {successes} | Fail: {len(failures)} | {rate:.1f}/sec | ETA: {remaining:.0f}s")
# Re-acquire token every 500 operations
if (i + 1) % 500 == 0:
print(" Re-acquiring token...")
token = get_token()
# Throttle: small pause every 50 to avoid 429
if (i + 1) % 50 == 0:
time.sleep(0.5)
elapsed = time.time() - start_time
results = {
"total_attempted": len(all_deletes),
"successes": successes,
"failures": len(failures),
"elapsed_seconds": round(elapsed, 1),
"failure_details": failures
}
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n{'=' * 60}")
print(f"DELETE RESULTS")
print(f"{'=' * 60}")
print(f" Total attempted: {len(all_deletes)}")
print(f" Successes: {successes}")
print(f" Failures: {len(failures)}")
print(f" Elapsed: {elapsed:.0f}s ({elapsed/60:.1f}m)")
print(f"[OK] Results saved to {RESULTS_FILE}")
if __name__ == "__main__":
main()

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""Step 4: Delete duplicate contacts in batches.
Usage: python bardach_dedup_step4_delete_batch.py [start_offset]
Processes 500 deletes per run. Saves progress."""
import json
import subprocess
import sys
import time
import os
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
PROGRESS_FILE = "D:/ClaudeTools/temp/bardach_dedup_delete_progress.json"
RESULTS_FILE = "D:/ClaudeTools/temp/bardach_dedup_delete_results.json"
BATCH_SIZE = 500
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}", flush=True)
sys.exit(1)
return data["access_token"]
def delete_contact(token, contact_id):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}",
"-w", "%{http_code}"],
capture_output=True, text=True
)
output = result.stdout.strip()
try:
status_code = int(output[-3:])
except (ValueError, IndexError):
status_code = 0
return status_code
def load_progress():
if os.path.exists(PROGRESS_FILE):
with open(PROGRESS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {"completed_index": 0, "successes": 0, "failures": []}
def save_progress(progress):
with open(PROGRESS_FILE, "w", encoding="utf-8") as f:
json.dump(progress, f, indent=2, ensure_ascii=False)
def main():
start_offset = int(sys.argv[1]) if len(sys.argv) > 1 else None
with open(PLAN_FILE, "r", encoding="utf-8") as f:
plan_data = json.load(f)
all_deletes = []
for entry in plan_data["plan"]:
for did in entry["delete_ids"]:
all_deletes.append({"id": did, "display_name": entry["display_name"]})
total = len(all_deletes)
progress = load_progress()
start = start_offset if start_offset is not None else progress["completed_index"]
end = min(start + BATCH_SIZE, total)
print(f"DELETE BATCH: {start}-{end} of {total} (batch size {BATCH_SIZE})", flush=True)
if start >= total:
print(f"[OK] All {total} deletes already processed!", flush=True)
print(f" Successes: {progress['successes']}", flush=True)
print(f" Failures: {len(progress['failures'])}", flush=True)
# Save final results
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
json.dump({
"total_attempted": total,
"successes": progress["successes"],
"failures": len(progress["failures"]),
"failure_details": progress["failures"]
}, f, indent=2, ensure_ascii=False)
return
token = get_token()
print("[OK] Token acquired", flush=True)
successes = progress["successes"]
failures = list(progress["failures"])
batch_start_time = time.time()
for i in range(start, end):
item = all_deletes[i]
status_code = delete_contact(token, item["id"])
if status_code == 204 or status_code == 200:
successes += 1
elif status_code == 404:
# Already deleted (maybe from previous run)
successes += 1
else:
failures.append({"display_name": item["display_name"], "id": item["id"], "status": status_code})
if (i - start + 1) % 100 == 0:
elapsed = time.time() - batch_start_time
rate = (i - start + 1) / elapsed if elapsed > 0 else 0
print(f" {i + 1}/{total} | Batch: {i - start + 1}/{end - start} | OK: {successes} | Fail: {len(failures)} | {rate:.1f}/sec", flush=True)
if (i - start + 1) % 50 == 0:
time.sleep(0.3)
# Save progress
progress = {"completed_index": end, "successes": successes, "failures": failures}
save_progress(progress)
elapsed = time.time() - batch_start_time
print(f"\nBatch complete: {start}-{end} in {elapsed:.0f}s", flush=True)
print(f" Total successes so far: {successes}", flush=True)
print(f" Total failures so far: {len(failures)}", flush=True)
print(f" Next batch starts at: {end}", flush=True)
if end < total:
print(f" Remaining: {total - end}", flush=True)
else:
print(f"[OK] ALL DELETES COMPLETE!", flush=True)
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
json.dump({
"total_attempted": total,
"successes": successes,
"failures": len(failures),
"failure_details": failures
}, f, indent=2, ensure_ascii=False)
print(f"[OK] Results saved to {RESULTS_FILE}", flush=True)
if __name__ == "__main__":
main()

View File

@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Step 5: Verify deduplication - pull contacts again and check for remaining duplicates."""
import json
import subprocess
import sys
from collections import defaultdict
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
FOLDER_ID = "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNAAuAAAAAADrk4YN-mpcR5zROC2646l9AQCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAAA="
SELECT_FIELDS = "id,displayName"
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}", flush=True)
sys.exit(1)
return data["access_token"]
def graph_get(token, url):
result = subprocess.run(
["curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"],
capture_output=True, text=True
)
return json.loads(result.stdout)
def main():
print("=" * 60, flush=True)
print("STEP 5: Verify deduplication", flush=True)
print("=" * 60, flush=True)
token = get_token()
print("[OK] Token acquired", flush=True)
# Pull all contacts (just id and displayName for speed)
contacts = []
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{FOLDER_ID}/contacts?$top=100&$select={SELECT_FIELDS}"
page = 1
while url:
data = graph_get(token, url)
if "value" not in data:
print(f"[ERROR] {data}", flush=True)
break
contacts.extend(data["value"])
if page % 20 == 0:
print(f" Page {page}, total so far: {len(contacts)}", flush=True)
url = data.get("@odata.nextLink")
page += 1
if page % 50 == 0:
token = get_token()
new_count = len(contacts)
old_count = 10404
print(f"\n{'=' * 60}", flush=True)
print(f"VERIFICATION RESULTS", flush=True)
print(f"{'=' * 60}", flush=True)
print(f" Old count (pre-dedup): {old_count}", flush=True)
print(f" New count (post-dedup): {new_count}", flush=True)
print(f" Contacts removed: {old_count - new_count}", flush=True)
# Check for remaining duplicates
groups = defaultdict(list)
for c in contacts:
name = (c.get("displayName") or "").strip().lower()
if name:
groups[name].append(c["id"])
remaining_dups = {name: ids for name, ids in groups.items() if len(ids) >= 2}
if remaining_dups:
print(f"\n[WARNING] Remaining duplicate groups: {len(remaining_dups)}", flush=True)
for name, ids in sorted(remaining_dups.items())[:10]:
print(f" {name}: {len(ids)} copies", flush=True)
else:
print(f"\n[OK] No duplicates remain! Deduplication complete.", flush=True)
print(f"\n Unique contact names: {len(groups)}", flush=True)
no_name = sum(1 for c in contacts if not (c.get("displayName") or "").strip())
print(f" Contacts without name: {no_name}", flush=True)
if __name__ == "__main__":
main()

View File

@@ -1,100 +0,0 @@
"""Search for deleted contacts in Barbara's mailbox using subprocess curl calls."""
import subprocess, json, sys, urllib.parse
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
USER = "barbara@bardach.net"
def curl_get(url, token, extra_headers=None):
cmd = ['curl', '-s', '-H', f'Authorization: Bearer {token}']
if extra_headers:
for k, v in extra_headers.items():
cmd.extend(['-H', f'{k}: {v}'])
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout) if result.stdout.strip() else {}
def curl_post(url, token, body):
cmd = ['curl', '-s', '-X', 'POST', '-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json', '-d', json.dumps(body), url]
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout) if result.stdout.strip() else {}
# Get token
print("[STEP 1] Getting token...")
token_cmd = subprocess.run([
'curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
'-d', f'client_id={CLAUDE_APP}&client_secret={CLAUDE_SECRET}&scope=https://graph.microsoft.com/.default&grant_type=client_credentials'
], capture_output=True, text=True)
token = json.loads(token_cmd.stdout)['access_token']
print("[OK] Token acquired")
# Method 1: Contact folders delta - finds deleted contacts
print("\n[STEP 2] Using contacts delta to enumerate all contacts including deleted...")
delta_url = f"https://graph.microsoft.com/beta/users/{USER}/contacts/delta?$select=displayName,emailAddresses"
all_active = []
all_removed = []
page = 0
while delta_url:
page += 1
data = curl_get(delta_url, token)
if 'error' in data:
print(f"[ERROR] {data['error'].get('code')}: {data['error'].get('message','')[:200]}")
break
items = data.get('value', [])
for item in items:
if '@removed' in item:
all_removed.append(item)
else:
all_active.append(item)
delta_url = data.get('@odata.nextLink')
delta_token_url = data.get('@odata.deltaLink')
if page % 5 == 0:
print(f" Page {page}: {len(all_active)} active, {len(all_removed)} removed...")
if not delta_url:
break
print(f"\n[RESULT] Delta scan complete:")
print(f" Active contacts: {len(all_active)}")
print(f" Deleted contacts: {len(all_removed)}")
if all_removed:
print(f"\n First 20 deleted contacts:")
for r in all_removed[:20]:
name = r.get('displayName', '(no name)')
rid = r.get('id', '?')[:30]
reason = r.get('@removed', {}).get('reason', '?')
print(f" {name} (reason: {reason})")
# Method 2: Check the Deleted Items folder for contact-class items
# Using the search API which handles IPM.Contact items
print(f"\n[STEP 3] Searching Deleted Items folder via search API...")
search_body = {
"requests": [{
"entityTypes": ["message"],
"query": {"queryString": "kind:contacts"},
"from": 0,
"size": 25
}]
}
search_result = curl_post(f"https://graph.microsoft.com/v1.0/users/{USER}/search/query", token, search_body)
if 'error' in search_result:
print(f"[INFO] Search API: {search_result['error'].get('code')}: {search_result['error'].get('message','')[:200]}")
elif search_result.get('value'):
for resp in search_result['value']:
hits = resp.get('hitsContainers', [{}])
for hc in hits:
total = hc.get('total', 0)
print(f" Search found {total} contact-related items")
for hit in hc.get('hits', [])[:10]:
resource = hit.get('resource', {})
print(f" {resource.get('subject', '?')}")
else:
print(f"[INFO] Search returned: {json.dumps(search_result)[:300]}")

View File

@@ -1,173 +0,0 @@
"""Search for deleted contacts using Graph search and extended properties."""
import subprocess, json, sys
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
USER = "barbara@bardach.net"
def curl(method, url, token, body=None):
cmd = ['curl', '-s', '-X', method, '-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json']
if body:
cmd.extend(['-d', json.dumps(body)])
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True)
try:
return json.loads(result.stdout) if result.stdout.strip() else {}
except json.JSONDecodeError:
return {'_raw': result.stdout[:500]}
# Get token
token_cmd = subprocess.run([
'curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
'-d', f'client_id={CLAUDE_APP}&client_secret={CLAUDE_SECRET}&scope=https://graph.microsoft.com/.default&grant_type=client_credentials'
], capture_output=True, text=True)
token = json.loads(token_cmd.stdout)['access_token']
print("[OK] Token acquired")
# Method 1: Use the search API (POST to /search/query at root level)
print("\n[METHOD 1] Graph Search API for contacts...")
search_body = {
"requests": [{
"entityTypes": ["contact"],
"query": {"queryString": "*"},
"from": 0,
"size": 25
}]
}
result = curl('POST', 'https://graph.microsoft.com/v1.0/search/query', token, search_body)
if 'error' in result:
print(f" {result['error'].get('code')}: {result['error'].get('message','')[:300]}")
elif result.get('value'):
for resp in result['value']:
for hc in resp.get('hitsContainers', []):
total = hc.get('total', 0)
more = hc.get('moreResultsAvailable', False)
print(f" Found {total} contacts (more available: {more})")
else:
print(f" Response: {json.dumps(result)[:300]}")
# Method 2: Enumerate Deleted Items looking for items with specific properties
# The Graph messages endpoint in deleted items will include contact items in some cases
# Let's get the deleted items folder children (subfolders) that might be contacts
print("\n[METHOD 2] Deleted Items sub-folders...")
result2 = curl('GET',
f'https://graph.microsoft.com/v1.0/users/{USER}/mailFolders/deleteditems/childFolders?$top=50&$select=displayName,totalItemCount,childFolderCount',
token)
if result2.get('value'):
for f in result2['value']:
print(f" {f.get('displayName','?')}: {f.get('totalItemCount','?')} items")
elif 'error' in result2:
print(f" {result2['error'].get('code')}: {result2['error'].get('message','')[:200]}")
# Method 3: Use beta to get contacts with explicit parentFolderId
# Actually, let's just scan all items in deleted items to count contacts vs mail
print("\n[METHOD 3] Scanning Deleted Items for item types...")
# Get count of items in deleted items
folder_info = curl('GET',
f'https://graph.microsoft.com/v1.0/users/{USER}/mailFolders/deleteditems?$select=totalItemCount,childFolderCount',
token)
total = folder_info.get('totalItemCount', 0)
print(f" Total items in Deleted Items: {total}")
print(f" Child folders: {folder_info.get('childFolderCount', 0)}")
# Method 4: Use the beta endpoint to get items with extended properties
# This lets us see the PR_MESSAGE_CLASS to identify contacts
print("\n[METHOD 4] Sampling Deleted Items with message class property...")
sample_url = (
f"https://graph.microsoft.com/beta/users/{USER}/mailFolders/deleteditems/messages"
f"?$top=100&$select=subject,lastModifiedDateTime"
f"&$expand=singleValueExtendedProperties($filter=id%20eq%20'String%200x001A')"
f"&$orderby=lastModifiedDateTime%20desc"
)
result4 = curl('GET', sample_url, token)
if result4.get('value'):
items = result4['value']
contact_count = 0
mail_count = 0
other_count = 0
contact_names = []
for item in items:
props = item.get('singleValueExtendedProperties', [])
msg_class = None
for p in props:
if p.get('id') == 'String 0x001A':
msg_class = p.get('value', '')
break
if msg_class and 'IPM.Contact' in msg_class:
contact_count += 1
contact_names.append(item.get('subject', '(no name)'))
elif msg_class and 'IPM.Note' in msg_class:
mail_count += 1
else:
other_count += 1
print(f" In sample of {len(items)} most recent deleted items:")
print(f" Contacts (IPM.Contact): {contact_count}")
print(f" Mail (IPM.Note): {mail_count}")
print(f" Other: {other_count}")
if contact_names:
print(f"\n Deleted contact names found:")
for name in contact_names[:20]:
print(f" - {name}")
# If we found contacts, let's page through ALL deleted items to count total contacts
if contact_count > 0:
print(f"\n[STEP 5] Full scan of Deleted Items for contacts...")
all_contacts = []
all_contact_names = []
scan_url = (
f"https://graph.microsoft.com/beta/users/{USER}/mailFolders/deleteditems/messages"
f"?$top=200&$select=subject,lastModifiedDateTime"
f"&$expand=singleValueExtendedProperties($filter=id%20eq%20'String%200x001A')"
)
page = 0
total_scanned = 0
while scan_url and page < 200: # Safety limit
page += 1
data = curl('GET', scan_url, token)
if 'error' in data:
print(f" Error on page {page}: {data['error'].get('message','')[:200]}")
break
items = data.get('value', [])
if not items:
break
total_scanned += len(items)
for item in items:
props = item.get('singleValueExtendedProperties', [])
for p in props:
if p.get('id') == 'String 0x001A' and 'IPM.Contact' in p.get('value', ''):
all_contacts.append(item)
all_contact_names.append(item.get('subject', '(no name)'))
break
scan_url = data.get('@odata.nextLink')
if page % 10 == 0:
print(f" Page {page}: scanned {total_scanned}, found {len(all_contacts)} contacts...")
print(f"\n[FINAL RESULT]")
print(f" Total items scanned: {total_scanned}")
print(f" Deleted contacts found: {len(all_contacts)}")
if all_contact_names:
# Save full list
with open('D:/ClaudeTools/temp/bardach_deleted_contacts.json', 'w') as f:
json.dump(all_contacts, f, indent=2)
print(f" Full list saved to D:/ClaudeTools/temp/bardach_deleted_contacts.json")
print(f"\n Sample of deleted contact names:")
for name in all_contact_names[:30]:
print(f" - {name}")
if len(all_contact_names) > 30:
print(f" ... and {len(all_contact_names) - 30} more")
elif 'error' in result4:
print(f" {result4['error'].get('code')}: {result4['error'].get('message','')[:300]}")
else:
print(f" Empty response")

View File

@@ -1,242 +0,0 @@
#!/usr/bin/env python3
"""Scan Barbara Bardach's email to find frequent correspondents missing from contacts."""
import subprocess
import json
import sys
import time
import urllib.parse
from collections import defaultdict
from datetime import datetime
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
APP_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
APP_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER_EMAIL = "barbara@bardach.net"
BASE_URL = f"https://graph.microsoft.com/v1.0/users/{USER_EMAIL}"
FILTER_KEYWORDS = ["noreply", "no-reply", "donotreply", "do-not-reply", "notification",
"alert", "mailer-daemon", "postmaster", "bounce", "automated",
"system", "daemon", "undeliverable"]
BARBARA_ALIASES = {"barbara@bardach.net"}
def get_token():
"""Get OAuth2 token using client credentials."""
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run([
"curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={APP_ID}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret={APP_SECRET}&grant_type=client_credentials"
], capture_output=True, text=True)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
print("[OK] Got access token")
return data["access_token"]
def graph_get(url, token):
"""Make a GET request to Graph API."""
result = subprocess.run([
"curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"
], capture_output=True, text=True)
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"[ERROR] Failed to parse response from {url[:100]}...")
print(f" stdout: {result.stdout[:200]}")
return None
def paginate_all(initial_url, token, label="items", max_pages=500):
"""Paginate through all results, refreshing token every 50 pages."""
all_items = []
url = initial_url
page = 0
current_token = token
while url and page < max_pages:
if page > 0 and page % 50 == 0:
print(f" Refreshing token at page {page}...")
current_token = get_token()
data = graph_get(url, current_token)
if data is None:
print(f" [WARNING] Null response at page {page}, stopping.")
break
if "error" in data:
print(f" [ERROR] API error at page {page}: {data['error'].get('message', '')}")
break
items = data.get("value", [])
all_items.extend(items)
page += 1
if page % 10 == 0:
print(f" [{label}] Page {page}: {len(all_items)} total so far...")
url = data.get("@odata.nextLink")
print(f" [{label}] Done: {len(all_items)} items across {page} pages")
return all_items, current_token
def is_automated(email):
"""Check if an email address looks automated."""
lower = email.lower()
for kw in FILTER_KEYWORDS:
if kw in lower:
return True
return False
def main():
start_time = time.time()
print("=" * 70)
print("Barbara Bardach - Email Contact Gap Analysis")
print("=" * 70)
# Step 1: Get token
token = get_token()
# Step 2: Pull all contacts
print("\n[INFO] Pulling contacts...")
contacts_url = f"{BASE_URL}/contacts?$top=999&$select=emailAddresses"
# Note: contacts URL doesn't have filter so $ signs are fine in query params
all_contacts, token = paginate_all(contacts_url, token, label="Contacts", max_pages=100)
contact_emails = set()
for c in all_contacts:
for ea in c.get("emailAddresses", []):
addr = ea.get("address", "").strip().lower()
if addr:
contact_emails.add(addr)
print(f"[OK] Found {len(all_contacts)} contacts with {len(contact_emails)} unique email addresses")
# Step 3: Pull SENT mail - last 12 months
print("\n[INFO] Pulling sent mail (last 12 months)...")
sent_params = urllib.parse.urlencode({
"$filter": "sentDateTime ge 2025-03-05T00:00:00Z",
"$select": "toRecipients,ccRecipients,subject,sentDateTime",
"$top": "250"
})
sent_url = f"{BASE_URL}/mailFolders/sentitems/messages?{sent_params}"
sent_messages, token = paginate_all(sent_url, token, label="Sent", max_pages=500)
# Step 4: Pull INBOX - last 12 months
print("\n[INFO] Pulling inbox (last 12 months)...")
inbox_params = urllib.parse.urlencode({
"$filter": "receivedDateTime ge 2025-03-05T00:00:00Z",
"$select": "from,subject,receivedDateTime",
"$top": "250"
})
inbox_url = f"{BASE_URL}/mailFolders/inbox/messages?{inbox_params}"
inbox_messages, token = paginate_all(inbox_url, token, label="Inbox", max_pages=500)
# Step 5 & 6: Count frequencies
print("\n[INFO] Counting frequencies...")
# Track email -> {sent_count, received_count, display_name}
email_data = defaultdict(lambda: {"sent_count": 0, "received_count": 0, "display_name": ""})
# Sent mail: count recipients
for msg in sent_messages:
for field in ["toRecipients", "ccRecipients"]:
for recip in msg.get(field, []) or []:
ea = recip.get("emailAddress", {})
addr = ea.get("address", "").strip().lower()
name = ea.get("name", "").strip()
if addr:
email_data[addr]["sent_count"] += 1
if name and not email_data[addr]["display_name"]:
email_data[addr]["display_name"] = name
# Inbox: count senders
for msg in inbox_messages:
fr = msg.get("from", {})
ea = fr.get("emailAddress", {}) if fr else {}
addr = ea.get("address", "").strip().lower() if ea else ""
name = ea.get("name", "").strip() if ea else ""
if addr:
email_data[addr]["received_count"] += 1
if name and not email_data[addr]["display_name"]:
email_data[addr]["display_name"] = name
total_unique = len(email_data)
print(f"[OK] Found {total_unique} unique email addresses in mail")
# Step 8: Filter
already_in_contacts = 0
filtered_out = 0
missing = []
for email, data in email_data.items():
if email in contact_emails:
already_in_contacts += 1
continue
if email in BARBARA_ALIASES:
filtered_out += 1
continue
if is_automated(email):
filtered_out += 1
continue
total = data["sent_count"] + data["received_count"]
missing.append({
"email": email,
"display_name": data["display_name"],
"sent_count": data["sent_count"],
"received_count": data["received_count"],
"total": total
})
# Sort by total descending
missing.sort(key=lambda x: x["total"], reverse=True)
# Step 10: Report
print("\n" + "=" * 70)
print("RESULTS")
print("=" * 70)
print(f"Total unique email addresses in mail: {total_unique}")
print(f"Already in contacts: {already_in_contacts}")
print(f"Filtered (Barbara/automated): {filtered_out}")
print(f"Missing from contacts: {len(missing)}")
print(f"Sent messages scanned: {len(sent_messages)}")
print(f"Inbox messages scanned: {len(inbox_messages)}")
print(f"\nTop 50 most frequent correspondents NOT in contacts:")
print("-" * 90)
print(f"{'#':>3} {'Email':<40} {'Name':<25} {'Sent':>5} {'Recv':>5} {'Total':>5}")
print("-" * 90)
for i, entry in enumerate(missing[:50], 1):
email_disp = entry["email"][:39]
name_disp = entry["display_name"][:24]
print(f"{i:>3} {email_disp:<40} {name_disp:<25} {entry['sent_count']:>5} {entry['received_count']:>5} {entry['total']:>5}")
# Step 11: Save JSON
output = {
"generated": datetime.now().isoformat(),
"total_mail_addresses": total_unique,
"already_in_contacts": already_in_contacts,
"missing_from_contacts": len(missing),
"sent_messages_scanned": len(sent_messages),
"inbox_messages_scanned": len(inbox_messages),
"missing": missing
}
output_path = r"D:\ClaudeTools\temp\bardach_missing_contacts.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
elapsed = time.time() - start_time
print(f"\n[OK] Full list saved to {output_path}")
print(f"[OK] Completed in {elapsed:.1f} seconds")
if __name__ == "__main__":
main()

View File

@@ -1,75 +0,0 @@
import urllib.request, urllib.parse, json, os
from collections import defaultdict
APP_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
USER_ID = "41d14430-feb4-4ae2-aed6-2bd4e6384ca7"
token_data = urllib.parse.urlencode({
'client_id': APP_ID, 'client_secret': CLIENT_SECRET,
'scope': 'https://graph.microsoft.com/.default', 'grant_type': 'client_credentials'
}).encode()
req = urllib.request.Request(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token", data=token_data, method='POST')
with urllib.request.urlopen(req) as r:
token = json.loads(r.read())['access_token']
base = f'https://graph.microsoft.com/v1.0/users/{USER_ID}/contacts'
def patch_contact(cid, data):
body = json.dumps(data).encode()
req = urllib.request.Request(f'{base}/{cid}', data=body, method='PATCH',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as r:
return r.status
# Fetch all contacts with blank displayName
url = f'{base}?$select=id,displayName,givenName,surname,companyName,emailAddresses,businessPhones,mobilePhone&$top=999'
blanks = []
while url:
req = urllib.request.Request(url, headers={'Authorization': f'Bearer {token}'})
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
for c in data.get('value', []):
dn = (c.get('displayName') or '').strip()
if not dn:
blanks.append(c)
url = data.get('@odata.nextLink')
print(f'Found {len(blanks)} contacts with blank displayName\n')
fixed = 0
skipped = 0
for c in blanks:
given = (c.get('givenName') or '').strip()
surname = (c.get('surname') or '').strip()
company = (c.get('companyName') or '').strip()
emails = [e.get('address', '') for e in c.get('emailAddresses', []) if e.get('address', '').strip()]
phones = list(filter(None, (c.get('businessPhones') or []) + [c.get('mobilePhone')]))
# Build display name from best available info
if given and surname:
new_name = f'{given} {surname}'
elif given:
new_name = given
elif surname:
new_name = surname
elif company:
new_name = company
elif emails:
# Use email local part as name
new_name = emails[0].split('@')[0].replace('.', ' ').replace('_', ' ').title()
else:
# Nothing useful - skip
skipped += 1
continue
try:
status = patch_contact(c['id'], {'displayName': new_name})
src = 'name' if (given or surname) else ('company' if company else 'email')
print(f' [OK] "{new_name}" (from {src}, status {status})')
fixed += 1
except Exception as e:
print(f' [ERROR] {new_name}: {e}')
print(f'\n=== DONE: Fixed {fixed}, Skipped {skipped} (no usable data) ===')

File diff suppressed because it is too large Load Diff

View File

@@ -1,288 +0,0 @@
#!/usr/bin/env python3
"""Find and analyze duplicate contacts in Barbara Bardach's Main Contacts folder."""
import subprocess
import json
import sys
from collections import defaultdict
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER = "barbara@bardach.net"
SELECT_FIELDS = "id,displayName,givenName,surname,emailAddresses,homePhones,businessPhones,companyName,jobTitle,personalNotes,homeAddress,businessAddress,birthday,lastModifiedDateTime"
def curl_json(args):
"""Run curl and return parsed JSON."""
result = subprocess.run(
["curl", "-s", "-S"] + args,
capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"[ERROR] curl failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"[ERROR] Invalid JSON response: {result.stdout[:500]}", file=sys.stderr)
sys.exit(1)
def get_token():
"""Get access token using client credentials flow."""
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
data = (
f"grant_type=client_credentials"
f"&client_id={CLIENT_ID}"
f"&client_secret={CLIENT_SECRET}"
f"&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default"
)
resp = curl_json([
"-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", data
])
if "access_token" not in resp:
print(f"[ERROR] Token request failed: {json.dumps(resp, indent=2)}", file=sys.stderr)
sys.exit(1)
print("[OK] Got access token")
return resp["access_token"]
def get_all_contacts(token):
"""Pull all contacts from the default contacts folder with pagination."""
contacts = []
url = (
f"https://graph.microsoft.com/v1.0/users/{USER}/contacts"
f"?$select={SELECT_FIELDS}&$top=250"
)
page = 1
while url:
print(f" Fetching page {page}...")
resp = curl_json([
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
url
])
if "error" in resp:
print(f"[ERROR] Graph API error: {json.dumps(resp['error'], indent=2)}", file=sys.stderr)
sys.exit(1)
batch = resp.get("value", [])
contacts.extend(batch)
print(f" Got {len(batch)} contacts (total: {len(contacts)})")
url = resp.get("@odata.nextLink")
page += 1
return contacts
def count_filled_fields(contact):
"""Count how many fields have meaningful data."""
score = 0
for key in ["givenName", "surname", "companyName", "jobTitle", "birthday"]:
if contact.get(key):
score += 1
if contact.get("personalNotes") and contact["personalNotes"].strip():
score += 2 # notes are valuable
for key in ["emailAddresses", "homePhones", "businessPhones"]:
val = contact.get(key)
if val and len(val) > 0:
score += len(val)
for key in ["homeAddress", "businessAddress"]:
addr = contact.get(key)
if addr and any(addr.get(f) for f in ["street", "city", "state", "postalCode"]):
score += 1
# Prefer more recently modified
return score
def summarize_differences(contacts):
"""Summarize what differs between duplicate contacts."""
diffs = []
fields_to_compare = [
"givenName", "surname", "companyName", "jobTitle", "birthday",
"personalNotes"
]
list_fields = ["emailAddresses", "homePhones", "businessPhones"]
addr_fields = ["homeAddress", "businessAddress"]
for field in fields_to_compare:
values = set()
for c in contacts:
v = c.get(field)
if v:
values.add(str(v).strip())
if len(values) > 1:
diffs.append(f"{field}: {values}")
elif len(values) == 1:
pass # same across all
# if 0, nobody has it
for field in list_fields:
all_vals = []
for c in contacts:
v = c.get(field, []) or []
if field == "emailAddresses":
items = sorted([e.get("address", "") for e in v if e.get("address")])
else:
items = sorted(v) if v else []
all_vals.append(tuple(items))
if len(set(all_vals)) > 1:
diffs.append(f"{field} differ: {[list(x) for x in all_vals]}")
for field in addr_fields:
addrs = []
for c in contacts:
a = c.get(field) or {}
parts = [a.get("street",""), a.get("city",""), a.get("state",""), a.get("postalCode","")]
addrs.append(tuple(p.strip() if p else "" for p in parts))
if len(set(addrs)) > 1:
diffs.append(f"{field} differ")
# Check lastModifiedDateTime
dates = [c.get("lastModifiedDateTime", "unknown") for c in contacts]
if len(set(dates)) > 1:
diffs.append(f"lastModified: {dates}")
return "; ".join(diffs) if diffs else "No differences found (exact duplicates)"
def analyze_duplicates(contacts):
"""Group by displayName and find duplicates."""
groups = defaultdict(list)
for c in contacts:
name = (c.get("displayName") or "").strip().lower()
if name:
groups[name].append(c)
duplicate_groups = []
for name, group in sorted(groups.items()):
if len(group) < 2:
continue
# Score each contact
scored = [(count_filled_fields(c), c.get("lastModifiedDateTime", ""), c) for c in group]
# Sort by score desc, then by lastModified desc
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
keeper = scored[0][2]
deletable = [s[2] for s in scored[1:]]
differences = summarize_differences(group)
duplicate_groups.append({
"name": group[0].get("displayName", name),
"count": len(group),
"contacts": group,
"keeper_id": keeper["id"],
"delete_ids": [c["id"] for c in deletable],
"differences": differences,
"_scores": [(s[0], s[2]["id"][:8]) for s in scored]
})
return duplicate_groups
def print_report(contacts, dup_groups):
"""Print a detailed report."""
total_removable = sum(len(g["delete_ids"]) for g in dup_groups)
print("\n" + "=" * 80)
print(f"DUPLICATE CONTACTS ANALYSIS - Barbara Bardach")
print("=" * 80)
print(f"Total contacts in Main Contacts: {len(contacts)}")
print(f"Duplicate groups found: {len(dup_groups)}")
print(f"Total removable contacts: {total_removable}")
print("=" * 80)
for i, g in enumerate(dup_groups, 1):
print(f"\n--- Group {i}: {g['name']} ({g['count']} contacts) ---")
for j, c in enumerate(g["contacts"]):
is_keeper = c["id"] == g["keeper_id"]
marker = "[KEEP]" if is_keeper else "[DELETE]"
score = [s[0] for s in g["_scores"] if s[1] == c["id"][:8]][0] if g.get("_scores") else "?"
print(f" {marker} (score={score}) id={c['id'][:12]}...")
print(f" displayName: {c.get('displayName')}")
print(f" givenName: {c.get('givenName')} surname: {c.get('surname')}")
emails = c.get("emailAddresses") or []
if emails:
print(f" emails: {[e.get('address') for e in emails]}")
hphones = c.get("homePhones") or []
if hphones:
print(f" homePhones: {hphones}")
bphones = c.get("businessPhones") or []
if bphones:
print(f" businessPhones: {bphones}")
if c.get("companyName"):
print(f" company: {c['companyName']}")
if c.get("jobTitle"):
print(f" jobTitle: {c['jobTitle']}")
if c.get("birthday"):
print(f" birthday: {c['birthday']}")
for addr_field in ["homeAddress", "businessAddress"]:
addr = c.get(addr_field) or {}
parts = [addr.get(f, "") for f in ["street", "city", "state", "postalCode"]]
if any(p for p in parts):
print(f" {addr_field}: {', '.join(p for p in parts if p)}")
notes = c.get("personalNotes", "")
if notes and notes.strip():
preview = notes.strip()[:80].replace("\n", " ")
print(f" notes: {preview}{'...' if len(notes.strip()) > 80 else ''}")
print(f" lastModified: {c.get('lastModifiedDateTime')}")
print(f" Differences: {g['differences']}")
return total_removable
def main():
print("[INFO] Starting duplicate contact analysis for Barbara Bardach")
# Step 1: Get token
token = get_token()
# Step 2+3: Get all contacts from default contacts folder
print("[INFO] Fetching all contacts from Main Contacts folder...")
contacts = get_all_contacts(token)
print(f"[OK] Retrieved {len(contacts)} total contacts")
if not contacts:
print("[WARNING] No contacts found!")
sys.exit(0)
# Step 4+5: Find duplicates
print("[INFO] Analyzing duplicates...")
dup_groups = analyze_duplicates(contacts)
# Step 6+7: Print report
total_removable = print_report(contacts, dup_groups)
# Step 8: Save analysis JSON
# Remove internal _scores from output
output_groups = []
for g in dup_groups:
out = dict(g)
out.pop("_scores", None)
output_groups.append(out)
analysis = {
"total_contacts": len(contacts),
"duplicate_groups": len(dup_groups),
"total_removable": total_removable,
"groups": output_groups
}
output_path = r"D:\ClaudeTools\temp\bardach_main_dupes_analysis.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(analysis, f, indent=2, default=str)
print(f"\n[OK] Analysis saved to {output_path}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +0,0 @@
"""
Merge and delete duplicate contacts in Barbara Bardach's main Contacts folder.
Reads analysis from bardach_main_dupes_analysis.json, merges data from delete
contacts into keepers, then deletes the duplicates.
"""
import json
import subprocess
import sys
import urllib.parse
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER_EMAIL = "barbara@bardach.net"
GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{USER_EMAIL}/contacts"
ANALYSIS_FILE = r"D:\ClaudeTools\temp\bardach_main_dupes_analysis.json"
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
data = (
f"grant_type=client_credentials"
f"&client_id={CLIENT_ID}"
f"&client_secret={urllib.parse.quote(CLIENT_SECRET, safe='')}"
f"&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default"
)
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", data],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
if "access_token" not in resp:
print(f"[ERROR] Failed to get token: {resp}")
sys.exit(1)
return resp["access_token"]
def get_contact(token, contact_id):
"""GET full contact details from Graph API."""
url = f"{GRAPH_BASE}/{contact_id}"
select = "$select=displayName,givenName,surname,emailAddresses,homePhones,businessPhones,personalNotes,companyName,jobTitle,homeAddress,businessAddress,birthday"
result = subprocess.run(
["curl", "-s", "-X", "GET", f"{url}?{select}",
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"],
capture_output=True, text=True
)
return json.loads(result.stdout)
def patch_contact(token, contact_id, payload):
"""PATCH a contact with the given payload."""
payload_json = json.dumps(payload)
url = f"{GRAPH_BASE}/{contact_id}"
result = subprocess.run(
["curl", "-s", "-X", "PATCH", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", payload_json,
"-w", "\n%{http_code}"],
capture_output=True, text=True
)
lines = result.stdout.strip().rsplit("\n", 1)
status = int(lines[-1]) if len(lines) > 1 else 0
return status, lines[0] if len(lines) > 1 else result.stdout
def delete_contact(token, contact_id):
"""DELETE a contact. Returns HTTP status code."""
url = f"{GRAPH_BASE}/{contact_id}"
result = subprocess.run(
["curl", "-s", "-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}",
"-o", "/dev/null",
"-w", "%{http_code}"],
capture_output=True, text=True
)
return int(result.stdout.strip())
def emails_equal(a, b):
"""Case-insensitive email comparison."""
return (a or "").lower().strip() == (b or "").lower().strip()
def has_address_data(addr):
"""Check if an address dict has any actual data."""
if not addr or not isinstance(addr, dict):
return False
return any(v for v in addr.values() if v)
def build_merge_payload(keeper, delete_contact_data):
"""Compare keeper and delete contact, return PATCH payload for fields to merge."""
payload = {}
merge_notes = []
# --- emailAddresses ---
keeper_emails = keeper.get("emailAddresses") or []
delete_emails = delete_contact_data.get("emailAddresses") or []
keeper_addrs = {e.get("address", "").lower().strip() for e in keeper_emails if e.get("address", "").strip()}
new_emails = []
for e in delete_emails:
addr = (e.get("address") or "").strip()
if addr and addr.lower() not in keeper_addrs:
new_emails.append(e)
if new_emails:
# Filter out empty entries from keeper too
clean_keeper = [e for e in keeper_emails if (e.get("address") or "").strip()]
payload["emailAddresses"] = clean_keeper + new_emails
merge_notes.append(f"emails: +{[e['address'] for e in new_emails]}")
# --- homePhones ---
keeper_hp = keeper.get("homePhones") or []
delete_hp = delete_contact_data.get("homePhones") or []
keeper_hp_set = {p.strip() for p in keeper_hp if p.strip()}
new_hp = [p for p in delete_hp if p.strip() and p.strip() not in keeper_hp_set]
if new_hp:
payload["homePhones"] = list(keeper_hp) + new_hp
merge_notes.append(f"homePhones: +{new_hp}")
# --- businessPhones ---
keeper_bp = keeper.get("businessPhones") or []
delete_bp = delete_contact_data.get("businessPhones") or []
keeper_bp_set = {p.strip() for p in keeper_bp if p.strip()}
new_bp = [p for p in delete_bp if p.strip() and p.strip() not in keeper_bp_set]
if new_bp:
payload["businessPhones"] = list(keeper_bp) + new_bp
merge_notes.append(f"businessPhones: +{new_bp}")
# --- personalNotes ---
keeper_notes = (keeper.get("personalNotes") or "").strip()
delete_notes = (delete_contact_data.get("personalNotes") or "").strip()
if delete_notes and not keeper_notes:
payload["personalNotes"] = delete_notes
merge_notes.append(f"personalNotes: set")
elif delete_notes and keeper_notes and delete_notes.lower() != keeper_notes.lower():
payload["personalNotes"] = keeper_notes + "\n" + delete_notes
merge_notes.append(f"personalNotes: appended")
# --- companyName ---
keeper_co = (keeper.get("companyName") or "").strip()
delete_co = (delete_contact_data.get("companyName") or "").strip()
if delete_co and not keeper_co:
payload["companyName"] = delete_co
merge_notes.append(f"companyName: '{delete_co}'")
# --- jobTitle ---
keeper_jt = (keeper.get("jobTitle") or "").strip()
delete_jt = (delete_contact_data.get("jobTitle") or "").strip()
if delete_jt and not keeper_jt:
payload["jobTitle"] = delete_jt
merge_notes.append(f"jobTitle: '{delete_jt}'")
# --- homeAddress ---
if has_address_data(delete_contact_data.get("homeAddress")) and not has_address_data(keeper.get("homeAddress")):
payload["homeAddress"] = delete_contact_data["homeAddress"]
merge_notes.append("homeAddress: set")
# --- businessAddress ---
if has_address_data(delete_contact_data.get("businessAddress")) and not has_address_data(keeper.get("businessAddress")):
payload["businessAddress"] = delete_contact_data["businessAddress"]
merge_notes.append("businessAddress: set")
# --- birthday ---
keeper_bday = keeper.get("birthday")
delete_bday = delete_contact_data.get("birthday")
if delete_bday and not keeper_bday:
payload["birthday"] = delete_bday
merge_notes.append(f"birthday: '{delete_bday}'")
return payload, merge_notes
def main():
with open(ANALYSIS_FILE, "r", encoding="utf-8") as f:
analysis = json.load(f)
groups = analysis["groups"]
print(f"Loaded {len(groups)} duplicate groups to process.\n")
token = get_token()
print("[OK] Got access token.\n")
success_count = 0
error_count = 0
for i, group in enumerate(groups, 1):
name = group["name"]
keeper_id = group["keeper_id"]
delete_ids = group["delete_ids"]
print(f"--- Group {i}/{len(groups)}: {name} ---")
# GET keeper details
keeper = get_contact(token, keeper_id)
if "error" in keeper:
print(f" [ERROR] Failed to GET keeper: {keeper['error']}")
error_count += 1
continue
for did in delete_ids:
# GET delete contact details
del_data = get_contact(token, did)
if "error" in del_data:
print(f" [ERROR] Failed to GET delete contact: {del_data['error']}")
error_count += 1
continue
# Build merge payload
payload, merge_notes = build_merge_payload(keeper, del_data)
if payload:
status, resp_body = patch_contact(token, keeper_id, payload)
if 200 <= status < 300:
print(f" [OK] PATCH keeper - merged: {', '.join(merge_notes)}")
# Update our local keeper data with the patched fields
keeper.update(payload)
else:
print(f" [ERROR] PATCH keeper failed (HTTP {status}): {resp_body[:200]}")
error_count += 1
continue
else:
print(f" [INFO] No data to merge from duplicate.")
# DELETE the duplicate
del_status = delete_contact(token, did)
if del_status == 204:
print(f" [OK] DELETE duplicate (HTTP 204)")
success_count += 1
else:
print(f" [ERROR] DELETE failed (HTTP {del_status})")
error_count += 1
print()
print(f"=== DONE: {success_count} deleted successfully, {error_count} errors ===")
if __name__ == "__main__":
main()

View File

@@ -1,217 +0,0 @@
"""Analyze Temp vs Contacts folders for merge strategy."""
import subprocess, json, sys
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER = "barbara@bardach.net"
# All contact fields we want to preserve
SELECT_FIELDS = (
"id,displayName,givenName,surname,middleName,nickName,"
"emailAddresses,homePhones,businessPhones,"
"companyName,jobTitle,department,"
"homeAddress,businessAddress,otherAddress,"
"birthday,personalNotes,"
"categories,title,generation,imAddresses,"
"parentFolderId"
)
def get_token():
r = subprocess.run([
'curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
'-d', f'client_id={CLAUDE_APP}&client_secret={CLAUDE_SECRET}&scope=https://graph.microsoft.com/.default&grant_type=client_credentials'
], capture_output=True, text=True)
return json.loads(r.stdout)['access_token']
def pull_contacts(token, folder_id=None, folder_name="default"):
if folder_id:
base = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{folder_id}/contacts"
else:
base = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts"
url = f"{base}?$top=100&$select={SELECT_FIELDS}"
all_contacts = []
page = 0
while url:
page += 1
r = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {token}', url],
capture_output=True, text=True)
data = json.loads(r.stdout)
if 'error' in data:
print(f" Error: {data['error'].get('message','')[:200]}")
break
items = data.get('value', [])
all_contacts.extend(items)
url = data.get('@odata.nextLink')
if page % 10 == 0:
print(f" {folder_name}: page {page}, {len(all_contacts)} contacts...")
if not items:
break
print(f" {folder_name}: {len(all_contacts)} total")
return all_contacts
token = get_token()
print("[OK] Token acquired\n")
# Get folder IDs
print("Getting contact folders...")
r = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {token}',
f'https://graph.microsoft.com/v1.0/users/{USER}/contactFolders?$select=displayName,id'],
capture_output=True, text=True)
folders = json.loads(r.stdout).get('value', [])
temp_id = None
contacts_id = None
for f in folders:
print(f" {f['displayName']}: {f['id'][:40]}...")
if f['displayName'] == 'Temp':
temp_id = f['id']
elif f['displayName'] == 'Contacts':
contacts_id = f['id']
if not temp_id:
print("[ERROR] Temp folder not found!")
sys.exit(1)
# Pull both folders
print("\nPulling Contacts folder...")
main_contacts = pull_contacts(token, contacts_id, "Contacts")
print("\nPulling Temp folder...")
temp_contacts = pull_contacts(token, temp_id, "Temp")
# Save raw data
with open('D:/ClaudeTools/temp/bardach_main_contacts.json', 'w') as f:
json.dump(main_contacts, f, indent=2)
with open('D:/ClaudeTools/temp/bardach_temp_contacts.json', 'w') as f:
json.dump(temp_contacts, f, indent=2)
print(f"\nSaved raw data files")
# Build matching keys
def make_key(c):
"""Create a matching key from name."""
name = (c.get('displayName') or '').strip().lower()
return name
def make_email_keys(c):
"""Get all email addresses as keys."""
return set(e.get('address', '').strip().lower()
for e in c.get('emailAddresses', [])
if e.get('address'))
# Index main contacts
main_by_name = {}
main_by_email = {}
for c in main_contacts:
key = make_key(c)
if key:
main_by_name.setdefault(key, []).append(c)
for email in make_email_keys(c):
main_by_email.setdefault(email, []).append(c)
# Categorize temp contacts
matched_by_name = []
matched_by_email = []
unmatched = []
blank = []
for c in temp_contacts:
key = make_key(c)
emails = make_email_keys(c)
if not key and not emails:
blank.append(c)
continue
if key and key in main_by_name:
matched_by_name.append((c, main_by_name[key]))
continue
email_match = None
for email in emails:
if email in main_by_email:
email_match = main_by_email[email]
break
if email_match:
matched_by_email.append((c, email_match))
else:
unmatched.append(c)
print(f"\n{'='*60}")
print(f"MERGE ANALYSIS")
print(f"{'='*60}")
print(f"Main Contacts folder: {len(main_contacts)}")
print(f"Temp folder (iCloud): {len(temp_contacts)}")
print(f"")
print(f"Matched by name: {len(matched_by_name)}")
print(f"Matched by email: {len(matched_by_email)}")
print(f"Unmatched (new): {len(unmatched)}")
print(f"Blank (no name/email):{len(blank)}")
print(f"Total categorized: {len(matched_by_name)+len(matched_by_email)+len(unmatched)+len(blank)}")
# Analyze what fields the temp contacts have that main doesn't
print(f"\n{'='*60}")
print(f"FIELD ANALYSIS - What Temp contacts add")
print(f"{'='*60}")
fields_to_check = ['personalNotes', 'companyName', 'jobTitle', 'birthday',
'homeAddress', 'businessAddress', 'homePhones', 'businessPhones',
'nickName', 'categories']
for field in fields_to_check:
temp_has = sum(1 for c in temp_contacts if c.get(field) and
(isinstance(c[field], str) and c[field].strip() or
isinstance(c[field], list) and len(c[field]) > 0 or
isinstance(c[field], dict) and any(v for v in c[field].values())))
main_has = sum(1 for c in main_contacts if c.get(field) and
(isinstance(c[field], str) and c[field].strip() or
isinstance(c[field], list) and len(c[field]) > 0 or
isinstance(c[field], dict) and any(v for v in c[field].values())))
print(f" {field:25s}: Temp={temp_has:5d} Main={main_has:5d}")
# For matched contacts, how many have notes in temp but not in main?
notes_to_merge = 0
for temp_c, main_matches in matched_by_name + matched_by_email:
temp_notes = (temp_c.get('personalNotes') or '').strip()
if temp_notes:
main_notes = (main_matches[0].get('personalNotes') or '').strip()
if not main_notes:
notes_to_merge += 1
print(f"\n Matched contacts where Temp has notes but Main doesn't: {notes_to_merge}")
# Sample unmatched
print(f"\n{'='*60}")
print(f"SAMPLE UNMATCHED (first 30 - these would be ADDED to main)")
print(f"{'='*60}")
for c in unmatched[:30]:
name = c.get('displayName', '(no name)')
emails = ', '.join(e.get('address','') for e in c.get('emailAddresses',[]))
company = c.get('companyName', '')
detail = emails or company or ''
print(f" {name}" + (f" - {detail}" if detail else ""))
if len(unmatched) > 30:
print(f" ... and {len(unmatched)-30} more")
# Sample blank
if blank:
print(f"\n{'='*60}")
print(f"BLANK CONTACTS ({len(blank)} - no displayName or email)")
print(f"{'='*60}")
for c in blank[:10]:
# Show whatever fields they have
non_empty = {k: v for k, v in c.items()
if v and k not in ('id', 'parentFolderId', '@odata.etag', 'changeKey',
'createdDateTime', 'lastModifiedDateTime', 'categories',
'flag', 'emailAddresses', 'homePhones', 'businessPhones',
'imAddresses')
and not k.startswith('@')}
print(f" Fields: {list(non_empty.keys())}")

View File

@@ -1,147 +0,0 @@
import urllib.request, urllib.parse, json, os
from collections import defaultdict
APP_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
USER_ID = "41d14430-feb4-4ae2-aed6-2bd4e6384ca7"
# Get token
token_data = urllib.parse.urlencode({
'client_id': APP_ID,
'client_secret': CLIENT_SECRET,
'scope': 'https://graph.microsoft.com/.default',
'grant_type': 'client_credentials'
}).encode()
req = urllib.request.Request(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token", data=token_data, method='POST')
with urllib.request.urlopen(req) as r:
token = json.loads(r.read())['access_token']
base = f'https://graph.microsoft.com/v1.0/users/{USER_ID}/contacts'
def patch_contact(cid, data):
body = json.dumps(data).encode()
req = urllib.request.Request(f'{base}/{cid}', data=body, method='PATCH',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as r:
return r.status
def delete_contact(cid):
req = urllib.request.Request(f'{base}/{cid}', method='DELETE',
headers={'Authorization': f'Bearer {token}'})
with urllib.request.urlopen(req) as r:
return r.status
# Fetch all contacts
url = f'{base}?$select=id,displayName,emailAddresses,companyName,businessPhones,mobilePhone,jobTitle,givenName,surname&$orderby=displayName&$top=999'
all_contacts = []
while url:
req = urllib.request.Request(url, headers={'Authorization': f'Bearer {token}'})
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
all_contacts.extend(data.get('value', []))
url = data.get('@odata.nextLink')
print(f'Total contacts: {len(all_contacts)}')
by_name = defaultdict(list)
for c in all_contacts:
name = c.get('displayName', '').strip()
if name:
by_name[name].append(c)
dupes = {k: v for k, v in by_name.items() if len(v) > 1}
print(f'Duplicate groups: {len(dupes)}')
def merge_emails(keeper, donor):
keeper_emails = set(e.get('address', '').lower() for e in keeper.get('emailAddresses', []) if e.get('address', '').strip())
new_emails = [e for e in keeper.get('emailAddresses', []) if e.get('address', '').strip()]
added = []
for e in donor.get('emailAddresses', []):
addr = e.get('address', '')
if addr.strip() and addr.lower() not in keeper_emails:
new_emails.append(e)
added.append(addr)
return new_emails, added
def merge_phones(keeper, donor):
def normalize(p):
return ''.join(c for c in p if c.isdigit())[-10:]
keeper_phones = set()
for p in (keeper.get('businessPhones') or []):
keeper_phones.add(normalize(p))
if keeper.get('mobilePhone'):
keeper_phones.add(normalize(keeper['mobilePhone']))
new_phones = []
for p in (donor.get('businessPhones') or []):
if normalize(p) not in keeper_phones:
new_phones.append(p)
if donor.get('mobilePhone') and normalize(donor['mobilePhone']) not in keeper_phones:
new_phones.append(donor['mobilePhone'])
return new_phones
def do_merge(name, keeper, donor):
new_emails, added_emails = merge_emails(keeper, donor)
new_phones = merge_phones(keeper, donor)
patch = {}
if added_emails:
patch['emailAddresses'] = new_emails
if new_phones:
biz = list(keeper.get('businessPhones') or []) + new_phones
patch['businessPhones'] = biz
if not keeper.get('companyName') and donor.get('companyName'):
patch['companyName'] = donor['companyName']
if not keeper.get('jobTitle') and donor.get('jobTitle'):
patch['jobTitle'] = donor['jobTitle']
if patch:
status = patch_contact(keeper['id'], patch)
extras = []
if added_emails: extras.append(f"emails: {added_emails}")
if new_phones: extras.append(f"phones: {new_phones}")
if 'companyName' in patch: extras.append(f"company: {patch['companyName']}")
if 'jobTitle' in patch: extras.append(f"job: {patch['jobTitle']}")
print(f' [OK] {name}: merged {", ".join(extras)} (status {status})')
else:
print(f' [OK] {name}: no new data to merge')
del_status = delete_contact(donor['id'])
print(f' Deleted duplicate (status {del_status})')
# === EXACT DUPLICATES ===
print('\n--- EXACT DUPLICATES ---')
for name in ['Bardach, Mike', 'Brandon Lopez', 'Judi Carroll', 'Kelly Yang', 'Megan Carroll', 'Winter Williams']:
contacts = dupes[name]
for c in contacts[1:]:
try:
status = delete_contact(c['id'])
print(f' [OK] Deleted: {name} (status {status})')
except Exception as e:
print(f' [ERROR] {name}: {e}')
# === PATSY SABLE (3 copies) ===
print('\n--- Patsy Sable (3 copies) ---')
patsy = dupes['Patsy Sable']
patsy_personal = [c for c in patsy if any(e.get('address', '') == 'patsy@patsysable.com' for e in c.get('emailAddresses', []))]
patsy_work = [c for c in patsy if any(e.get('address', '') == 'psable@longrealty.com' for e in c.get('emailAddresses', []))]
if len(patsy_work) >= 2:
try:
status = delete_contact(patsy_work[1]['id'])
print(f' [OK] Deleted exact work dupe (status {status})')
except Exception as e:
print(f' [ERROR] work dupe: {e}')
if patsy_personal and patsy_work:
try:
do_merge('Patsy Sable', patsy_personal[0], patsy_work[0])
except Exception as e:
print(f' [ERROR] merge: {e}')
# === MERGE PAIRS ===
print('\n--- MERGE PAIRS ---')
for name in ['Barbara Bardach', 'David Rodriguez', 'Denise Newton', 'Gina Beltran',
'Jessica Bonn', 'Kayla Manley', 'Maria Anemone', 'Mark Crager',
'Paula Williams', 'Randy Bonn', 'Susan Barry']:
contacts = dupes[name]
try:
do_merge(name, contacts[0], contacts[1])
except Exception as e:
print(f' [ERROR] {name}: {e}')
print('\n=== ALL DONE ===')

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""
Bardach Contact Delete: Delete remaining Temp contacts.
Treats 404 as success (contact already gone).
Merges were already completed successfully (70/70).
"""
import json
import subprocess
import time
import sys
from datetime import datetime
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
BASE_URL = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts"
DATA_FILE = "D:/ClaudeTools/temp/bardach_temp_vs_main.json"
THROTTLE_DELAY = 0.25 # slightly faster since deletes are simple
def get_token():
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={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Token failed: {data}")
sys.exit(1)
print(f"[OK] Token acquired at {datetime.now().strftime('%H:%M:%S')}")
return data["access_token"]
def api_delete(token, contact_id):
"""DELETE a contact. Returns status code as string."""
url = f"{BASE_URL}/{contact_id}"
cmd = [
"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}"
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result.stdout.strip()
def api_get(token, url):
cmd = [
"curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return json.loads(result.stdout)
# Load data
with open(DATA_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
matches = data["matches_with_extras"]
exact_matches = data.get("exact_matches", [])
all_temp_ids = []
for m in matches:
all_temp_ids.append((m["temp_id"], m["displayName"]))
for m in exact_matches:
all_temp_ids.append((m["temp_id"], m["displayName"]))
print(f"[INFO] Total Temp contacts to delete: {len(all_temp_ids)}")
token = get_token()
deleted_ok = 0
already_gone = 0
real_errors = 0
error_details = []
for i, (tid, name) in enumerate(all_temp_ids):
if i > 0 and i % 500 == 0:
token = get_token()
if i > 0 and i % 200 == 0:
print(f" [INFO] Progress {i}/{len(all_temp_ids)}: {deleted_ok} deleted, {already_gone} already gone, {real_errors} errors")
code = api_delete(token, tid)
time.sleep(THROTTLE_DELAY)
if code in ("204", "200"):
deleted_ok += 1
elif code == "404":
already_gone += 1
else:
real_errors += 1
if real_errors <= 10:
print(f" [ERROR] {name}: HTTP {code}")
error_details.append({"name": name, "code": code, "temp_id": tid})
print(f"\n[OK] Delete complete:")
print(f" Deleted now: {deleted_ok}")
print(f" Already gone: {already_gone}")
print(f" Errors: {real_errors}")
# Verification
print("\n" + "=" * 70)
print("VERIFICATION")
print("=" * 70)
token = get_token()
# Check Temp folder
folders_url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders?$filter=displayName eq 'Temp'"
folders_resp = api_get(token, folders_url)
time.sleep(0.5)
if "value" in folders_resp and folders_resp["value"]:
temp_folder_id = folders_resp["value"][0]["id"]
count_url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{temp_folder_id}/contacts?$top=1&$select=displayName&$count=true"
count_resp = api_get(token, count_url)
remaining = len(count_resp.get("value", []))
odata_count = count_resp.get("@odata.count", "N/A")
has_more = "@odata.nextLink" in count_resp
print(f" Temp folder: odata.count={odata_count}, first page={remaining}, has_more={has_more}")
if remaining > 0:
print(f" First remaining: {count_resp['value'][0].get('displayName', '?')}")
else:
print(f" Temp folder: not found or empty")
# Check Main contacts
main_count_url = f"{BASE_URL}?$top=1&$select=displayName&$count=true"
main_resp = api_get(token, main_count_url)
main_odata = main_resp.get("@odata.count", "N/A")
print(f" Main contacts: odata.count={main_odata}")
# Save results
results = {
"timestamp": datetime.now().isoformat(),
"merge_step": "Completed previously: 70/70 patches successful",
"deletes": {
"total_attempted": len(all_temp_ids),
"deleted_now": deleted_ok,
"already_gone": already_gone,
"errors": real_errors,
"error_samples": error_details[:20],
},
"verification": {
"temp_odata_count": str(odata_count) if 'odata_count' in dir() else "N/A",
"main_odata_count": str(main_odata),
}
}
with open("D:/ClaudeTools/temp/bardach_merge_results.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, default=str)
print(f"\n[OK] Results saved to bardach_merge_results.json")

View File

@@ -1,49 +0,0 @@
{
"timestamp": "2026-03-04T12:37:00",
"operation": "Bardach Temp -> Main contact merge and cleanup",
"step1_notes_analysis": {
"icloud_junk": 3730,
"real_content": 23,
"no_notes_field": 135,
"total_with_extras": 3888
},
"step2_merge_plan": {
"contacts_needing_merge": 265,
"contacts_nothing_to_merge": 3623,
"contacts_needing_fetch": 235,
"field_counts": {
"homePhones": 114,
"businessPhones": 97,
"emailAddresses": 65,
"personalNotes": 23,
"companyName": 12,
"businessAddress": 9,
"homeAddress": 7,
"birthday": 3,
"jobTitle": 2,
"otherAddress": 2
}
},
"step3_fetches": {
"total": 235,
"success": 235,
"errors": 0
},
"step4_patches": {
"total_built": 70,
"skipped_no_change_after_dedup": 195,
"success": 70,
"failures": 0
},
"step5_deletes": {
"total_contacts": 5680,
"from_matches_with_extras": 3888,
"from_exact_matches": 1792,
"all_confirmed_gone": true,
"errors": 0
},
"step6_verification": {
"temp_folder_contacts_remaining": 0,
"main_contacts_count": 6071
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,414 +0,0 @@
#!/usr/bin/env python3
"""Find real two-way correspondents missing from Barbara's contacts and extract phone numbers from signatures."""
import json
import re
import subprocess
import time
import html
import urllib.parse
from datetime import datetime
# ── Config ──
INPUT_FILE = r"D:\ClaudeTools\temp\bardach_missing_contacts.json"
OUTPUT_FILE = r"D:\ClaudeTools\temp\bardach_missing_real_contacts.json"
TENANT = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER_EMAIL = "barbara@bardach.net"
TOKEN_URL = f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token"
GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{USER_EMAIL}"
# ── Junk filters ──
JUNK_KEYWORDS = [
"noreply", "no-reply", "donotreply", "notification", "alert",
"mailer-daemon", "postmaster", "unsubscribe", "bounce",
"support@", "info@", "help@", "service@", "billing@",
"news@", "newsletter", "marketing", "promo"
]
COMMERCIAL_DOMAINS = [
"amazon.com", "google.com", "facebook.com", "apple.com", "microsoft.com",
"paypal.com", "ebay.com", "nextdoor.com", "linkedin.com", "twitter.com",
"instagram.com", "fidelity.com", "schwab.com", "vanguard.com",
"intuit.com", "turbotax.com"
]
# ── Token management ──
_token = None
_api_call_count = 0
def get_token():
"""Get a fresh OAuth2 token."""
result = subprocess.run(
["curl", "-s", "-X", "POST", TOKEN_URL,
"-d", f"client_id={CLIENT_ID}",
"-d", f"client_secret={CLIENT_SECRET}",
"-d", "scope=https://graph.microsoft.com/.default",
"-d", "grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Token request failed: {data}")
raise RuntimeError("Failed to get token")
return data["access_token"]
def refresh_token_if_needed():
"""Refresh token every 30 API calls."""
global _token, _api_call_count
if _token is None or _api_call_count >= 30:
_token = get_token()
_api_call_count = 0
print(f" [Token refreshed]")
return _token
def graph_get(url, retries=3):
"""Make a GET request to Graph API using curl -G with --data-urlencode for proper encoding."""
global _api_call_count
token = refresh_token_if_needed()
_api_call_count += 1
for attempt in range(retries):
result = subprocess.run(
["curl", "-s", "--url", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-H", "ConsistencyLevel: eventual"],
capture_output=True, text=True
)
if not result.stdout:
if attempt < retries - 1:
time.sleep(2)
continue
return None
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
if attempt < retries - 1:
time.sleep(2)
continue
return None
if "error" in data:
code = data["error"].get("code", "")
if code in ("TooManyRequests", "ServiceUnavailable", "GatewayTimeout") or "429" in str(code):
wait = 5 * (attempt + 1)
print(f" [Throttled, waiting {wait}s...]")
time.sleep(wait)
token = get_token()
_api_call_count = 0
continue
return None
return data
return None
def graph_search(email, top=3):
"""Search messages from a specific email using $search (which works, unlike $filter on from)."""
global _api_call_count
token = refresh_token_if_needed()
_api_call_count += 1
base_url = f"{GRAPH_BASE}/messages"
for attempt in range(3):
result = subprocess.run(
["curl", "-s", "-G", base_url,
"--data-urlencode", f"$search=\"from:{email}\"",
"--data-urlencode", "$select=subject,from,body",
"--data-urlencode", f"$top={top}",
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-H", "ConsistencyLevel: eventual"],
capture_output=True, text=True
)
if not result.stdout:
if attempt < 2:
time.sleep(2)
continue
return None
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
if attempt < 2:
time.sleep(2)
continue
return None
if "error" in data:
code = data["error"].get("code", "")
if code in ("TooManyRequests", "ServiceUnavailable", "GatewayTimeout") or "429" in str(code):
wait = 5 * (attempt + 1)
print(f" [Throttled, waiting {wait}s...]")
time.sleep(wait)
token = get_token()
_api_call_count = 0
continue
return None
return data
return None
# ── Phone extraction ──
PHONE_RE = re.compile(r'[\(]?\d{3}[\)\s.\-]?\s?\d{3}[\s.\-]?\d{4}')
LABELED_PHONE_RE = re.compile(
r'(?:Tel|Phone|Cell|Mobile|Office|Direct|Fax)[:\s]*\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}',
re.IGNORECASE
)
LABEL_RE = re.compile(r'(Tel|Phone|Cell|Mobile|Office|Direct|Fax)', re.IGNORECASE)
SIGNATURE_MARKERS = [
'--', '---', '____', '====', 'Best regards', 'Kind regards', 'Regards',
'Sincerely', 'Thank you', 'Thanks', 'Sent from', 'Get Outlook',
'Best,', 'Cheers', 'Warm regards', 'All the best'
]
# Markers that indicate the start of a quoted/forwarded reply (stop searching past these)
REPLY_MARKERS = [
'From:', 'Sent:', '-----Original Message', '________________________________',
'On ', '> On ', 'Begin forwarded message', 'wrote:'
]
def strip_html(text):
"""Remove HTML tags and decode entities."""
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'</?(?:p|div|tr|td|li|blockquote|table|tbody|thead|th|hr)[^>]*>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
text = html.unescape(text)
# Collapse multiple blank lines
text = re.sub(r'\n{3,}', '\n\n', text)
return text
def extract_first_message_body(body_html):
"""Extract just the first (most recent) message from a thread, cutting off quoted replies."""
text = strip_html(body_html)
lines = text.split('\n')
# Find where the quoted reply starts (typically after the first message + signature)
# Look for reply markers starting from line 5 (skip subject/header area)
cutoff = len(lines)
for i in range(5, len(lines)):
line = lines[i].strip()
# "From: Name <email>" pattern indicating quoted message
if re.match(r'^From:\s+.+', line) and i > 10:
cutoff = i
break
# "On <date>, <name> wrote:" pattern
if re.match(r'^On .+wrote:\s*$', line):
cutoff = i
break
if '-----Original Message' in line:
cutoff = i
break
if line.startswith('________________________________'):
cutoff = i
break
return '\n'.join(lines[:cutoff])
def extract_phone_from_body(body_html, sender_email):
"""Extract phone number from email signature area of the FIRST message only."""
if not body_html:
return None, None
# Get just the first message (not quoted replies) to avoid picking up OTHER people's numbers
first_msg = extract_first_message_body(body_html)
lines = first_msg.split('\n')
# Find signature start - search from bottom up for signature markers
sig_start = None
for i in range(len(lines) - 1, max(len(lines) - 40, -1), -1):
line = lines[i].strip()
for marker in SIGNATURE_MARKERS:
if marker.lower() in line.lower():
sig_start = i
break
if sig_start is not None:
break
# If no signature marker found, use last 25 lines of first message
if sig_start is None:
sig_start = max(0, len(lines) - 25)
sig_text = '\n'.join(lines[sig_start:])
# First try labeled phone numbers in signature
labeled = LABELED_PHONE_RE.search(sig_text)
if labeled:
match_text = labeled.group(0)
label_match = LABEL_RE.search(match_text)
label = label_match.group(1).capitalize() if label_match else None
phone = PHONE_RE.search(match_text)
if phone:
return normalize_phone(phone.group(0)), label
# Then try any phone number in signature
phone = PHONE_RE.search(sig_text)
if phone:
return normalize_phone(phone.group(0)), None
# Fallback: search entire first message for labeled phones
labeled_full = LABELED_PHONE_RE.search(first_msg)
if labeled_full:
match_text = labeled_full.group(0)
label_match = LABEL_RE.search(match_text)
label = label_match.group(1).capitalize() if label_match else None
phone = PHONE_RE.search(match_text)
if phone:
return normalize_phone(phone.group(0)), label
# Last resort: any phone in the first message
phone = PHONE_RE.search(first_msg)
if phone:
return normalize_phone(phone.group(0)), None
return None, None
def normalize_phone(raw):
"""Normalize phone to (xxx) xxx-xxxx format."""
digits = re.sub(r'\D', '', raw)
if len(digits) == 11 and digits[0] == '1':
digits = digits[1:]
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
return raw.strip()
# ── Main ──
def main():
print("=" * 80)
print(" Bardach Missing Real Contacts - Phone Number Finder")
print("=" * 80)
# 1. Load input
with open(INPUT_FILE, encoding='utf-8') as f:
data = json.load(f)
missing = data["missing"]
print(f"\n[INFO] Total missing contacts loaded: {len(missing)}")
# 2. Filter sent_count > 0
two_way = [c for c in missing if c["sent_count"] > 0]
print(f"[INFO] Two-way correspondents (sent_count > 0): {len(two_way)}")
# 3. Filter junk
def is_junk(email):
email_lower = email.lower()
for kw in JUNK_KEYWORDS:
if kw in email_lower:
return True
domain = email_lower.split('@')[-1] if '@' in email_lower else ''
for cd in COMMERCIAL_DOMAINS:
if domain == cd or domain.endswith('.' + cd):
return True
return False
real = [c for c in two_way if not is_junk(c["email"])]
print(f"[INFO] After junk filter: {len(real)}")
# 4. Sort by total descending
real.sort(key=lambda c: c["total"], reverse=True)
print(f"\n[SUCCESS] {len(real)} real two-way correspondents are missing from contacts\n")
# 5. Phone lookup for top 60
top_n = min(60, len(real))
print(f"[INFO] Searching for phone numbers in top {top_n} contacts...")
print("-" * 80)
results = []
phones_found = 0
for idx, contact in enumerate(real[:top_n]):
email = contact["email"]
name = contact["display_name"] or email.split('@')[0]
print(f" [{idx+1:2d}/{top_n}] {name[:35]:35s} <{email[:40]}>", end="", flush=True)
# Search for 3 most recent emails FROM this address using $search
phone = None
phone_label = None
resp = graph_search(email, top=3)
if resp and "value" in resp:
for msg in resp["value"]:
# Verify this message is actually FROM the target email
msg_from = msg.get("from", {}).get("emailAddress", {}).get("address", "").lower()
if msg_from != email.lower():
continue
body_content = msg.get("body", {}).get("content", "")
phone, phone_label = extract_phone_from_body(body_content, email)
if phone:
break
if phone:
phones_found += 1
label_str = f" ({phone_label})" if phone_label else ""
print(f" -> {phone}{label_str}")
else:
print(f" -> --")
results.append({
"email": email,
"display_name": contact["display_name"],
"sent_count": contact["sent_count"],
"received_count": contact["received_count"],
"total": contact["total"],
"phone": phone,
"phone_label": phone_label
})
# Add remaining contacts (beyond top 60) without phone lookup
for contact in real[top_n:]:
results.append({
"email": contact["email"],
"display_name": contact["display_name"],
"sent_count": contact["sent_count"],
"received_count": contact["received_count"],
"total": contact["total"],
"phone": None,
"phone_label": None
})
# 7. Save output
output = {
"generated": datetime.now().isoformat(),
"total_two_way": len(real),
"with_phone": phones_found,
"without_phone": len(real) - phones_found,
"contacts": results
}
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"\n[SUCCESS] Saved to {OUTPUT_FILE}")
# 8. Print table
print(f"\n{'='*110}")
print(f" MISSING REAL CONTACTS - TOP {top_n} (sorted by total exchanges)")
print(f"{'='*110}")
print(f" {'#':>3} {'Name':<30} {'Email':<40} {'Total':>6} {'Phone':<25}")
print(f" {'-'*3} {'-'*30} {'-'*40} {'-'*6} {'-'*25}")
for i, c in enumerate(results[:top_n]):
name = (c["display_name"] or c["email"].split('@')[0])[:30]
email_short = c["email"][:40]
phone_str = c["phone"] or "--"
if c["phone_label"]:
phone_str = f"{c['phone']} ({c['phone_label']})"
print(f" {i+1:3d} {name:<30} {email_short:<40} {c['total']:6d} {phone_str}")
print(f"\n{'='*110}")
print(f" SUMMARY")
print(f"{'='*110}")
print(f" Total two-way correspondents missing: {len(real)}")
print(f" Phone numbers found (top {top_n}): {phones_found}")
print(f" Without phone (top {top_n}): {top_n - phones_found}")
print(f"{'='*110}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,503 +0,0 @@
#!/usr/bin/env python3
"""
Bardach Contacts - Notes Analysis
Pulls all contacts from main Contacts folder, analyzes personalNotes
for junk, duplication, promotable data, and cross-contact duplicates.
"""
import subprocess
import json
import re
import sys
from collections import defaultdict
from datetime import datetime
# --- Config ---
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
OUTPUT_FILE = "D:/ClaudeTools/temp/bardach_notes_analysis.json"
TOP = 100
TOKEN_REFRESH_INTERVAL = 500
# --- Helpers ---
def get_token():
result = subprocess.run([
"curl", "-s", "-X", "POST",
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}",
"-d", f"client_secret={CLIENT_SECRET}",
"-d", f"scope={SCOPE}",
"-d", "grant_type=client_credentials"
], capture_output=True, text=True)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Token acquisition failed: {data}")
sys.exit(1)
return data["access_token"]
def api_get(url, token):
result = subprocess.run([
"curl", "-s",
"-H", f"Authorization: Bearer {token}",
url
], capture_output=True, text=True)
return json.loads(result.stdout)
def pull_all_contacts(token):
"""Pull all contacts from default Contacts folder with pagination."""
select_fields = (
"id,displayName,givenName,surname,emailAddresses,homePhones,"
"businessPhones,mobilePhone,companyName,jobTitle,personalNotes,"
"homeAddress,businessAddress,otherAddress,birthday,lastModifiedDateTime"
)
url = (
f"https://graph.microsoft.com/v1.0/users/{USER}/contacts"
f"?$select={select_fields}&$top={TOP}"
)
all_contacts = []
api_calls = 0
page = 0
while url:
page += 1
api_calls += 1
# Re-acquire token every N calls
if api_calls % TOKEN_REFRESH_INTERVAL == 0:
print(f" Re-acquiring token after {api_calls} API calls...")
token = get_token()
print(f" Fetching page {page} ({len(all_contacts)} contacts so far)...")
data = api_get(url, token)
if "value" not in data:
print(f"[ERROR] Unexpected response: {json.dumps(data)[:500]}")
break
all_contacts.extend(data["value"])
url = data.get("@odata.nextLink")
print(f" Total contacts fetched: {len(all_contacts)} in {api_calls} API calls")
return all_contacts, token
# --- Analysis Functions ---
ICLOUD_PATTERNS = [
r"this contact is read[\s-]*only",
r"edit.*in outlook",
r"tap the link",
r"this contact was created from a read[\s-]*only account",
r"read[\s-]*only contact",
r"icloud",
]
PHONE_PATTERNS = [
r'\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}',
r'\+?\d[\d\s.\-]{7,14}\d',
r'\d{3}[\s.\-]\d{4}',
]
EMAIL_PATTERN = r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
def normalize_phone(p):
"""Strip phone to digits only for comparison."""
return re.sub(r'\D', '', str(p))
def extract_phones_from_text(text):
"""Extract phone numbers from free text."""
phones = set()
for pat in PHONE_PATTERNS:
for m in re.finditer(pat, text):
digits = normalize_phone(m.group())
if len(digits) >= 7:
phones.add(digits)
return phones
def extract_emails_from_text(text):
"""Extract email addresses from free text."""
return {e.lower() for e in re.findall(EMAIL_PATTERN, text)}
def get_contact_phones(c):
"""Get all phone numbers from structured fields."""
phones = set()
for p in c.get("homePhones") or []:
d = normalize_phone(p)
if d:
phones.add(d)
for p in c.get("businessPhones") or []:
d = normalize_phone(p)
if d:
phones.add(d)
mob = c.get("mobilePhone")
if mob:
d = normalize_phone(mob)
if d:
phones.add(d)
return phones
def get_contact_emails(c):
"""Get all emails from structured fields."""
emails = set()
for e in c.get("emailAddresses") or []:
addr = (e.get("address") or "").lower().strip()
if addr:
emails.add(addr)
return emails
def format_address(addr):
"""Convert address dict to string for comparison."""
if not addr:
return ""
parts = []
for k in ["street", "city", "state", "postalCode", "countryOrRegion"]:
v = (addr.get(k) or "").strip()
if v:
parts.append(v)
return " ".join(parts).lower()
def analyze_notes(contacts):
report = {}
# Separate contacts with/without notes
with_notes = []
without_notes = []
for c in contacts:
notes = (c.get("personalNotes") or "").strip()
if notes:
with_notes.append(c)
else:
without_notes.append(c)
# --- A. Junk/Boilerplate Notes ---
icloud_warnings = []
empty_whitespace = []
for c in contacts:
raw_notes = c.get("personalNotes") or ""
stripped = raw_notes.strip()
if raw_notes and not stripped:
empty_whitespace.append({
"id": c["id"],
"displayName": c.get("displayName", ""),
"note_repr": repr(raw_notes[:100])
})
continue
if stripped:
lower = stripped.lower()
for pat in ICLOUD_PATTERNS:
if re.search(pat, lower):
icloud_warnings.append({
"id": c["id"],
"displayName": c.get("displayName", ""),
"note_preview": stripped[:200]
})
break
report["A_junk_boilerplate"] = {
"icloud_warnings_count": len(icloud_warnings),
"icloud_warnings": icloud_warnings,
"empty_whitespace_count": len(empty_whitespace),
"empty_whitespace": empty_whitespace
}
print(f"\n[A] Junk/Boilerplate: {len(icloud_warnings)} iCloud warnings, {len(empty_whitespace)} empty/whitespace")
# --- B. Notes that duplicate structured fields ---
dup_phones = []
dup_emails = []
dup_company = []
dup_jobtitle = []
dup_address = []
for c in with_notes:
notes = c.get("personalNotes", "").strip()
notes_lower = notes.lower()
name = c.get("displayName", "")
# Phone duplication
note_phones = extract_phones_from_text(notes)
field_phones = get_contact_phones(c)
overlap_phones = note_phones & field_phones
if overlap_phones:
dup_phones.append({
"displayName": name,
"duplicated_phones": list(overlap_phones)
})
# Email duplication
note_emails = extract_emails_from_text(notes)
field_emails = get_contact_emails(c)
overlap_emails = note_emails & field_emails
if overlap_emails:
dup_emails.append({
"displayName": name,
"duplicated_emails": list(overlap_emails)
})
# Company duplication
company = (c.get("companyName") or "").strip().lower()
if company and len(company) > 2 and company in notes_lower:
dup_company.append({
"displayName": name,
"company": c.get("companyName")
})
# Job title duplication
title = (c.get("jobTitle") or "").strip().lower()
if title and len(title) > 2 and title in notes_lower:
dup_jobtitle.append({
"displayName": name,
"jobTitle": c.get("jobTitle")
})
# Address duplication
for addr_field in ["homeAddress", "businessAddress", "otherAddress"]:
addr_str = format_address(c.get(addr_field))
if addr_str and len(addr_str) > 5:
# Check if significant parts of address appear in notes
addr_parts = [p for p in addr_str.split() if len(p) > 3]
matches = sum(1 for p in addr_parts if p in notes_lower)
if len(addr_parts) > 0 and matches >= len(addr_parts) * 0.5:
dup_address.append({
"displayName": name,
"field": addr_field,
"address": format_address(c.get(addr_field))
})
break # one match per contact is enough
report["B_duplicates_in_notes"] = {
"phones_duplicated_count": len(dup_phones),
"phones_duplicated": dup_phones,
"emails_duplicated_count": len(dup_emails),
"emails_duplicated": dup_emails,
"company_duplicated_count": len(dup_company),
"company_duplicated": dup_company,
"jobtitle_duplicated_count": len(dup_jobtitle),
"jobtitle_duplicated": dup_jobtitle,
"address_duplicated_count": len(dup_address),
"address_duplicated": dup_address
}
print(f"[B] Duplicated in notes: {len(dup_phones)} phones, {len(dup_emails)} emails, "
f"{len(dup_company)} companies, {len(dup_jobtitle)} titles, {len(dup_address)} addresses")
# --- C. Notes with structured data that SHOULD be in fields ---
promotable_phones = []
promotable_emails = []
for c in with_notes:
notes = c.get("personalNotes", "").strip()
name = c.get("displayName", "")
# Phones in notes NOT in fields
note_phones = extract_phones_from_text(notes)
field_phones = get_contact_phones(c)
extra_phones = note_phones - field_phones
if extra_phones:
promotable_phones.append({
"displayName": name,
"phones_in_notes_only": list(extra_phones),
"note_preview": notes[:200]
})
# Emails in notes NOT in fields
note_emails = extract_emails_from_text(notes)
field_emails = get_contact_emails(c)
extra_emails = note_emails - field_emails
if extra_emails:
promotable_emails.append({
"displayName": name,
"emails_in_notes_only": list(extra_emails),
"note_preview": notes[:200]
})
report["C_promotable_data"] = {
"phones_promotable_count": len(promotable_phones),
"phones_promotable": promotable_phones,
"emails_promotable_count": len(promotable_emails),
"emails_promotable": promotable_emails
}
print(f"[C] Promotable data: {len(promotable_phones)} contacts with phones in notes only, "
f"{len(promotable_emails)} contacts with emails in notes only")
# --- D. Duplicate notes across contacts ---
notes_groups = defaultdict(list)
for c in with_notes:
notes = c.get("personalNotes", "").strip()
if notes:
notes_groups[notes].append(c.get("displayName", c["id"]))
duplicate_groups = []
for notes_text, names in sorted(notes_groups.items(), key=lambda x: -len(x[1])):
if len(names) >= 2:
duplicate_groups.append({
"note_preview": notes_text[:200],
"count": len(names),
"contacts": names
})
report["D_duplicate_notes_across_contacts"] = {
"groups_count": len(duplicate_groups),
"groups": duplicate_groups
}
print(f"[D] Duplicate notes across contacts: {len(duplicate_groups)} groups")
# --- E. General statistics ---
note_lengths = [len(c.get("personalNotes", "").strip()) for c in with_notes]
buckets = {"1-50": 0, "51-200": 0, "201-500": 0, "500+": 0}
for l in note_lengths:
if l <= 50:
buckets["1-50"] += 1
elif l <= 200:
buckets["51-200"] += 1
elif l <= 500:
buckets["201-500"] += 1
else:
buckets["500+"] += 1
avg_len = sum(note_lengths) / len(note_lengths) if note_lengths else 0
# Sample 20 notes of varying lengths
sorted_by_len = sorted(with_notes, key=lambda c: len(c.get("personalNotes", "")))
sample_indices = []
n = len(sorted_by_len)
if n <= 20:
sample_indices = list(range(n))
else:
step = n / 20
sample_indices = [int(i * step) for i in range(20)]
samples = []
for i in sample_indices:
c = sorted_by_len[i]
notes = c.get("personalNotes", "").strip()
samples.append({
"displayName": c.get("displayName", ""),
"note_length": len(notes),
"note_preview": notes[:200]
})
report["E_statistics"] = {
"total_contacts": len(contacts),
"contacts_with_notes": len(with_notes),
"contacts_without_notes": len(without_notes),
"average_note_length": round(avg_len, 1),
"length_distribution": buckets,
"sample_notes": samples
}
print(f"[E] Stats: {len(contacts)} total, {len(with_notes)} with notes, "
f"{len(without_notes)} without, avg length {avg_len:.1f}")
return report
def main():
print("=" * 60)
print("Bardach Contacts - Notes Analysis")
print("=" * 60)
print("\n[1] Acquiring token...")
token = get_token()
print(" [OK] Token acquired")
print("\n[2] Pulling all contacts...")
contacts, token = pull_all_contacts(token)
print(f"\n[3] Analyzing notes across {len(contacts)} contacts...")
report = analyze_notes(contacts)
report["_metadata"] = {
"generated": datetime.now().isoformat(),
"total_contacts_analyzed": len(contacts),
"user": USER
}
print(f"\n[4] Saving report to {OUTPUT_FILE}...")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False, default=str)
print(" [OK] Report saved")
# --- Print comprehensive report ---
print("\n" + "=" * 60)
print("COMPREHENSIVE NOTES ANALYSIS REPORT")
print("=" * 60)
print(f"\nTotal contacts: {report['E_statistics']['total_contacts']}")
print(f"With notes: {report['E_statistics']['contacts_with_notes']}")
print(f"Without notes: {report['E_statistics']['contacts_without_notes']}")
print(f"Average note length: {report['E_statistics']['average_note_length']} chars")
print(f"\n--- A. Junk/Boilerplate ---")
a = report["A_junk_boilerplate"]
print(f"iCloud warnings: {a['icloud_warnings_count']}")
for item in a["icloud_warnings"]:
print(f" - {item['displayName']}: {item['note_preview'][:80]}")
print(f"Empty/whitespace notes: {a['empty_whitespace_count']}")
for item in a["empty_whitespace"]:
print(f" - {item['displayName']}")
print(f"\n--- B. Notes Duplicating Structured Fields ---")
b = report["B_duplicates_in_notes"]
print(f"Phone numbers duplicated: {b['phones_duplicated_count']}")
for item in b["phones_duplicated"]:
print(f" - {item['displayName']}: {item['duplicated_phones']}")
print(f"Emails duplicated: {b['emails_duplicated_count']}")
for item in b["emails_duplicated"]:
print(f" - {item['displayName']}: {item['duplicated_emails']}")
print(f"Company names duplicated: {b['company_duplicated_count']}")
for item in b["company_duplicated"]:
print(f" - {item['displayName']}: {item['company']}")
print(f"Job titles duplicated: {b['jobtitle_duplicated_count']}")
for item in b["jobtitle_duplicated"]:
print(f" - {item['displayName']}: {item['jobTitle']}")
print(f"Addresses duplicated: {b['address_duplicated_count']}")
for item in b["address_duplicated"]:
print(f" - {item['displayName']}: {item['field']} = {item['address']}")
print(f"\n--- C. Promotable Data (in notes but NOT in fields) ---")
c_data = report["C_promotable_data"]
print(f"Contacts with phones in notes only: {c_data['phones_promotable_count']}")
for item in c_data["phones_promotable"]:
print(f" - {item['displayName']}: {item['phones_in_notes_only']}")
print(f"Contacts with emails in notes only: {c_data['emails_promotable_count']}")
for item in c_data["emails_promotable"]:
print(f" - {item['displayName']}: {item['emails_in_notes_only']}")
print(f"\n--- D. Duplicate Notes Across Contacts ---")
d = report["D_duplicate_notes_across_contacts"]
print(f"Groups with identical notes: {d['groups_count']}")
for g in d["groups"]:
print(f" - {g['count']} contacts share: \"{g['note_preview'][:100]}\"")
for name in g["contacts"]:
print(f" {name}")
print(f"\n--- E. Note Length Distribution ---")
dist = report["E_statistics"]["length_distribution"]
for bucket, count in dist.items():
print(f" {bucket}: {count}")
print(f"\n--- E. Sample Notes (20 samples, varying lengths) ---")
for s in report["E_statistics"]["sample_notes"]:
print(f" [{s['note_length']} chars] {s['displayName']}: {s['note_preview'][:120]}")
print("\n[DONE]")
if __name__ == "__main__":
main()

View File

@@ -1,129 +0,0 @@
import urllib.request, urllib.parse, json, sys
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
TENANT = "bardach.net"
def get_token(tid, cid, secret, scope):
data = urllib.parse.urlencode({
'client_id': cid, 'client_secret': secret,
'scope': scope, 'grant_type': 'client_credentials'
}).encode()
req = urllib.request.Request(
f"https://login.microsoftonline.com/{tid}/oauth2/v2.0/token",
data=data, method='POST')
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())['access_token']
def graph_get(token, url):
req = urllib.request.Request(url, headers={'Authorization': f'Bearer {token}'})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def graph_post(token, url, body):
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method='POST',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
# Step 1: Get Graph token
print("[STEP 1] Getting Graph token...")
token = get_token(TENANT_ID, CLAUDE_APP, CLAUDE_SECRET, "https://graph.microsoft.com/.default")
print("[OK] Graph token acquired")
# Step 2: Find Claude SP
print("\n[STEP 2] Finding Claude SP...")
sp_filter = urllib.parse.quote(f"appId eq '{CLAUDE_APP}'")
sp_result = graph_get(token, f"https://graph.microsoft.com/v1.0/servicePrincipals?$filter={sp_filter}&$select=id,displayName")
if sp_result.get('value'):
sp = sp_result['value'][0]
sp_id = sp['id']
print(f"[OK] SP: {sp['displayName']} (ID: {sp_id})")
else:
print("[ERROR] Claude SP not found")
sys.exit(1)
# Step 3: Check granted app role assignments
print("\n[STEP 3] Checking granted permissions...")
try:
grants = graph_get(token, f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}/appRoleAssignments")
roles = grants.get('value', [])
print(f"[INFO] {len(roles)} app role assignments")
# Get unique resource names
resources = set()
for r in roles:
resources.add(r.get('resourceDisplayName', '?'))
for res in sorted(resources):
count = sum(1 for r in roles if r.get('resourceDisplayName') == res)
print(f" {res}: {count} permissions")
except urllib.error.HTTPError as e:
print(f"[INFO] Cannot read appRoleAssignments: HTTP {e.code}")
# Step 4: Find Exchange Admin role
print("\n[STEP 4] Finding Exchange Administrator role...")
try:
roles_result = graph_get(token, "https://graph.microsoft.com/v1.0/directoryRoles?$select=id,displayName,roleTemplateId")
exo_role = None
for role in roles_result.get('value', []):
if role.get('displayName') == 'Exchange Administrator':
exo_role = role
break
if not exo_role:
print("[INFO] Exchange Admin not activated, activating from template...")
try:
activate = graph_post(token, "https://graph.microsoft.com/v1.0/directoryRoles",
{"roleTemplateId": "29232cdf-9323-42fd-ade2-1d097af3e4de"})
exo_role = activate
print(f"[OK] Activated: {activate.get('id')}")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] Activation failed: HTTP {e.code} - {body[:200]}")
sys.exit(1)
exo_role_id = exo_role['id']
print(f"[OK] Exchange Admin Role ID: {exo_role_id}")
except urllib.error.HTTPError as e:
print(f"[ERROR] Cannot list directory roles: HTTP {e.code}")
sys.exit(1)
# Step 5: Assign Exchange Admin to Claude SP
print("\n[STEP 5] Assigning Exchange Admin role to Claude SP...")
try:
assign_body = {"@odata.id": f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}"}
graph_post(token, f"https://graph.microsoft.com/v1.0/directoryRoles/{exo_role_id}/members/$ref", assign_body)
print("[OK] Exchange Administrator assigned!")
except urllib.error.HTTPError as e:
body = e.read().decode()
if 'already exist' in body.lower():
print("[OK] Exchange Administrator already assigned")
else:
print(f"[ERROR] HTTP {e.code}: {body[:300]}")
# Step 6: Test Exchange REST API
print("\n[STEP 6] Testing Exchange REST API...")
exo_token = get_token(TENANT_ID, CLAUDE_APP, CLAUDE_SECRET, "https://outlook.office365.com/.default")
invoke_url = f"https://outlook.office365.com/adminapi/beta/{TENANT_ID}/InvokeCommand"
headers = {'Authorization': f'Bearer {exo_token}', 'Content-Type': 'application/json'}
cmd = json.dumps({
"CmdletInput": {
"CmdletName": "Get-Mailbox",
"Parameters": {"Identity": "barbara@bardach.net", "ResultSize": "1"}
}
}).encode()
try:
req = urllib.request.Request(invoke_url, data=cmd, headers=headers, method='POST')
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
if result.get('value'):
mb = result['value'][0]
print(f"[OK] Exchange access works - {mb.get('DisplayName', '?')}")
else:
print(f"[OK] Exchange responded: {json.dumps(result)[:200]}")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] Exchange REST: HTTP {e.code} - {body[:300]}")

View File

@@ -1,9 +0,0 @@
{
"operation": "delete_exact_matches",
"total": 1792,
"successes": 1792,
"failures": 0,
"note": "Exact matches were already deleted in a prior session. All 1792 IDs return 404, and Temp folder count dropped from 5973 to 4181 (difference = 1792).",
"elapsed_seconds": 0,
"failed_contacts": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
{
"operation": "delete_blank_contacts",
"total": 15,
"successes": 15,
"failures": 0,
"failed_contacts": []
}

View File

@@ -1,105 +0,0 @@
"""
Operation 1: Delete exact match contacts from Temp folder (1,792 contacts)
"""
import json
import subprocess
import time
import urllib.parse
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
DATA_FILE = "D:/ClaudeTools/temp/bardach_temp_vs_main.json"
OUTPUT_FILE = "D:/ClaudeTools/temp/bardach_op1_delete_exact.json"
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
data = (
f"client_id={CLIENT_ID}"
f"&scope={urllib.parse.quote(SCOPE)}"
f"&client_secret={urllib.parse.quote(CLIENT_SECRET)}"
f"&grant_type=client_credentials"
)
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", data],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
if "access_token" not in resp:
print(f"[ERROR] Token acquisition failed: {resp}")
raise Exception("Failed to get token")
print("[OK] Token acquired")
return resp["access_token"]
def delete_contact(token, contact_id):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
return result.stdout.strip()
def main():
print("=" * 60)
print("OPERATION 1: Delete exact matches from Temp (1,792 contacts)")
print("=" * 60)
with open(DATA_FILE, "r") as f:
data = json.load(f)
exact_matches = data["exact_matches"]
total = len(exact_matches)
print(f"[INFO] Loaded {total} exact matches to delete")
token = get_token()
successes = []
failures = []
start_time = time.time()
for i, contact in enumerate(exact_matches):
# Re-acquire token every 500 operations
if i > 0 and i % 500 == 0:
print(f"[INFO] Re-acquiring token at operation {i}...")
token = get_token()
temp_id = contact["temp_id"]
status_code = delete_contact(token, temp_id)
if status_code in ("204", "200"):
successes.append({"temp_id": temp_id, "displayName": contact.get("displayName", "")})
else:
failures.append({"temp_id": temp_id, "displayName": contact.get("displayName", ""), "status": status_code})
# Progress every 100
if (i + 1) % 100 == 0 or (i + 1) == total:
elapsed = time.time() - start_time
rate = (i + 1) / elapsed if elapsed > 0 else 0
print(f" [{i+1}/{total}] OK={len(successes)} FAIL={len(failures)} ({rate:.1f}/sec)")
elapsed = time.time() - start_time
results = {
"operation": "delete_exact_matches",
"total": total,
"successes": len(successes),
"failures": len(failures),
"elapsed_seconds": round(elapsed, 1),
"failed_contacts": failures
}
with open(OUTPUT_FILE, "w") as f:
json.dump(results, f, indent=2)
print(f"\n[SUCCESS] Operation 1 complete")
print(f" Deleted: {len(successes)}/{total}")
print(f" Failed: {len(failures)}/{total}")
print(f" Time: {elapsed:.1f}s")
print(f" Results: {OUTPUT_FILE}")
if __name__ == "__main__":
main()

View File

@@ -1,248 +0,0 @@
"""
Operation 2: Move unique contacts from Temp to Main Contacts folder (278 contacts)
Tries move endpoint first, falls back to copy+delete.
"""
import json
import subprocess
import time
import urllib.parse
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
DATA_FILE = "D:/ClaudeTools/temp/bardach_temp_vs_main.json"
OUTPUT_FILE = "D:/ClaudeTools/temp/bardach_op2_move_unique.json"
# Contact fields to copy in fallback mode
CONTACT_FIELDS = [
"givenName", "surname", "displayName", "middleName", "nickName",
"title", "jobTitle", "companyName", "department", "officeLocation",
"businessHomePage", "personalNotes", "generation", "imAddresses",
"emailAddresses", "homePhones", "mobilePhone", "businessPhones",
"homeAddress", "businessAddress", "otherAddress",
"birthday", "yomiGivenName", "yomiSurname", "yomiCompanyName",
"fileAs", "initials", "manager", "assistantName", "profession",
"spouseName", "children"
]
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
data = (
f"client_id={CLIENT_ID}"
f"&scope={urllib.parse.quote(SCOPE)}"
f"&client_secret={urllib.parse.quote(CLIENT_SECRET)}"
f"&grant_type=client_credentials"
)
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", data],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
if "access_token" not in resp:
print(f"[ERROR] Token acquisition failed: {resp}")
raise Exception("Failed to get token")
print("[OK] Token acquired")
return resp["access_token"]
def get_contacts_folder_id(token):
"""Get the default Contacts folder ID."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders"
result = subprocess.run(
["curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
for folder in resp.get("value", []):
if folder.get("displayName") == "Contacts":
return folder["id"]
return None
def try_move(token, contact_id, dest_id):
"""Try the /move endpoint."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}/move"
body = json.dumps({"destinationId": dest_id})
result = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}", "-X", "POST", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", body],
capture_output=True, text=True
)
lines = result.stdout.rsplit("\n", 1)
status = lines[-1].strip() if len(lines) > 1 else "000"
body_text = lines[0] if len(lines) > 1 else result.stdout
return status, body_text
def get_contact(token, contact_id):
"""Read full contact data."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
return json.loads(result.stdout)
def create_contact(token, contact_data):
"""Create a contact in the default Contacts folder."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts"
# Build clean payload with only writable fields
payload = {}
for field in CONTACT_FIELDS:
val = contact_data.get(field)
if val is not None and val != "" and val != [] and val != {}:
payload[field] = val
body = json.dumps(payload)
result = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}", "-X", "POST", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", body],
capture_output=True, text=True
)
lines = result.stdout.rsplit("\n", 1)
status = lines[-1].strip() if len(lines) > 1 else "000"
body_text = lines[0] if len(lines) > 1 else result.stdout
return status, body_text
def delete_contact(token, contact_id):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
return result.stdout.strip()
def main():
print("=" * 60)
print("OPERATION 2: Move unique contacts to Main Contacts (278)")
print("=" * 60)
with open(DATA_FILE, "r") as f:
data = json.load(f)
unique = data["unique_to_temp"]
total = len(unique)
print(f"[INFO] Loaded {total} unique contacts to move")
token = get_token()
# Get the contacts folder ID for move endpoint
folder_id = get_contacts_folder_id(token)
print(f"[INFO] Main Contacts folder ID: {folder_id}")
# Test move endpoint with first contact
move_works = False
if total > 0:
test_id = unique[0]["temp_id"]
# Try with "contacts" string first
status, body = try_move(token, test_id, "contacts")
if status in ("200", "201"):
move_works = True
print("[OK] Move endpoint works with 'contacts' destination")
elif folder_id:
# Try with actual folder ID
status, body = try_move(token, test_id, folder_id)
if status in ("200", "201"):
move_works = True
print("[OK] Move endpoint works with folder ID")
else:
print(f"[WARNING] Move endpoint returned {status}, falling back to copy+delete")
print(f" Response: {body[:200]}")
else:
print(f"[WARNING] Move returned {status} and no folder ID found, using copy+delete")
use_move = move_works
dest_id = "contacts" if not folder_id else folder_id
# If the first contact was already moved successfully via test, track it
start_index = 1 if move_works else 0
successes = []
failures = []
method_used = "move" if use_move else "copy+delete"
print(f"[INFO] Using method: {method_used}")
if move_works:
# First one already moved
successes.append({
"temp_id": unique[0]["temp_id"],
"displayName": unique[0].get("displayName", ""),
"method": "move"
})
start_time = time.time()
for i in range(start_index, total):
contact = unique[i]
# Re-acquire token every 250 operations
if i > 0 and i % 250 == 0:
print(f"[INFO] Re-acquiring token at operation {i}...")
token = get_token()
temp_id = contact["temp_id"]
display = contact.get("displayName", "")
if use_move:
status, body = try_move(token, temp_id, dest_id)
if status in ("200", "201"):
successes.append({"temp_id": temp_id, "displayName": display, "method": "move"})
else:
failures.append({"temp_id": temp_id, "displayName": display, "status": status, "error": body[:200]})
else:
# Fallback: copy + delete
try:
cdata = get_contact(token, temp_id)
if "error" in cdata:
failures.append({"temp_id": temp_id, "displayName": display, "status": "read_fail", "error": str(cdata["error"])[:200]})
continue
cstatus, cbody = create_contact(token, cdata)
if cstatus in ("200", "201"):
# Delete original
dstatus = delete_contact(token, temp_id)
if dstatus in ("204", "200"):
successes.append({"temp_id": temp_id, "displayName": display, "method": "copy+delete"})
else:
successes.append({"temp_id": temp_id, "displayName": display, "method": "copy_only", "delete_status": dstatus})
else:
failures.append({"temp_id": temp_id, "displayName": display, "status": cstatus, "error": cbody[:200]})
except Exception as e:
failures.append({"temp_id": temp_id, "displayName": display, "status": "exception", "error": str(e)[:200]})
# Progress every 25
if (i + 1) % 25 == 0 or (i + 1) == total:
elapsed = time.time() - start_time
rate = (i + 1) / elapsed if elapsed > 0 else 0
print(f" [{i+1}/{total}] OK={len(successes)} FAIL={len(failures)} ({rate:.1f}/sec)")
elapsed = time.time() - start_time
results = {
"operation": "move_unique_contacts",
"method": method_used,
"total": total,
"successes": len(successes),
"failures": len(failures),
"elapsed_seconds": round(elapsed, 1),
"successful_contacts": successes,
"failed_contacts": failures
}
with open(OUTPUT_FILE, "w") as f:
json.dump(results, f, indent=2)
print(f"\n[SUCCESS] Operation 2 complete")
print(f" Moved: {len(successes)}/{total}")
print(f" Failed: {len(failures)}/{total}")
print(f" Method: {method_used}")
print(f" Time: {elapsed:.1f}s")
print(f" Results: {OUTPUT_FILE}")
if __name__ == "__main__":
main()

View File

@@ -1,92 +0,0 @@
"""
Operation 3: Delete blank/empty contacts from Temp (15 contacts)
"""
import json
import subprocess
import time
import urllib.parse
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
DATA_FILE = "D:/ClaudeTools/temp/bardach_temp_vs_main.json"
OUTPUT_FILE = "D:/ClaudeTools/temp/bardach_op3_delete_blank.json"
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
data = (
f"client_id={CLIENT_ID}"
f"&scope={urllib.parse.quote(SCOPE)}"
f"&client_secret={urllib.parse.quote(CLIENT_SECRET)}"
f"&grant_type=client_credentials"
)
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", data],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
if "access_token" not in resp:
print(f"[ERROR] Token acquisition failed: {resp}")
raise Exception("Failed to get token")
print("[OK] Token acquired")
return resp["access_token"]
def delete_contact(token, contact_id):
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "DELETE", url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
return result.stdout.strip()
def main():
print("=" * 60)
print("OPERATION 3: Delete blank contacts from Temp (15 contacts)")
print("=" * 60)
with open(DATA_FILE, "r") as f:
data = json.load(f)
blanks = data["blank"]
total = len(blanks)
print(f"[INFO] Loaded {total} blank contacts to delete")
token = get_token()
successes = []
failures = []
for i, contact in enumerate(blanks):
temp_id = contact["temp_id"]
status_code = delete_contact(token, temp_id)
if status_code in ("204", "200"):
successes.append({"temp_id": temp_id})
print(f" [{i+1}/{total}] DELETED blank contact {temp_id[:40]}... -> {status_code}")
else:
failures.append({"temp_id": temp_id, "status": status_code})
print(f" [{i+1}/{total}] FAILED blank contact {temp_id[:40]}... -> {status_code}")
results = {
"operation": "delete_blank_contacts",
"total": total,
"successes": len(successes),
"failures": len(failures),
"failed_contacts": failures
}
with open(OUTPUT_FILE, "w") as f:
json.dump(results, f, indent=2)
print(f"\n[SUCCESS] Operation 3 complete")
print(f" Deleted: {len(successes)}/{total}")
print(f" Failed: {len(failures)}/{total}")
print(f" Results: {OUTPUT_FILE}")
if __name__ == "__main__":
main()

View File

@@ -1,140 +0,0 @@
"""
Final Verification: Count remaining Temp contacts and summarize all operations.
"""
import json
import subprocess
import urllib.parse
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
TEMP_FOLDER_ID = None # Will be resolved dynamically
def get_token():
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
data = (
f"client_id={CLIENT_ID}"
f"&scope={urllib.parse.quote(SCOPE)}"
f"&client_secret={urllib.parse.quote(CLIENT_SECRET)}"
f"&grant_type=client_credentials"
)
result = subprocess.run(
["curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", data],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
if "access_token" not in resp:
print(f"[ERROR] Token acquisition failed: {resp}")
raise Exception("Failed to get token")
return resp["access_token"]
def count_temp_contacts(token):
"""Count contacts in Temp folder using $count."""
url = (
f"https://graph.microsoft.com/v1.0/users/{USER}"
f"/contactFolders/{TEMP_FOLDER_ID}/contacts?$count=true&$top=1&$select=id"
)
result = subprocess.run(
["curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "ConsistencyLevel: eventual"],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
count = resp.get("@odata.count")
if count is not None:
return count
# Fallback: page through all
print("[INFO] @odata.count not available, paging through contacts...")
total = 0
page_url = (
f"https://graph.microsoft.com/v1.0/users/{USER}"
f"/contactFolders/{TEMP_FOLDER_ID}/contacts?$top=100&$select=id"
)
while page_url:
result = subprocess.run(
["curl", "-s", "-X", "GET", page_url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
resp = json.loads(result.stdout)
contacts = resp.get("value", [])
total += len(contacts)
page_url = resp.get("@odata.nextLink")
if total % 500 == 0 and total > 0:
print(f" ...counted {total} so far")
return total
def main():
print("=" * 60)
print("FINAL VERIFICATION")
print("=" * 60)
token = get_token()
print("[OK] Token acquired")
# Find Temp folder ID
global TEMP_FOLDER_ID
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders"
result = subprocess.run(
["curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}"],
capture_output=True, text=True
)
folders = json.loads(result.stdout).get("value", [])
for f in folders:
if "temp" in f.get("displayName", "").lower():
TEMP_FOLDER_ID = f["id"]
print(f"[INFO] Found Temp folder: '{f['displayName']}' -> {TEMP_FOLDER_ID[:40]}...")
break
if not TEMP_FOLDER_ID:
print("[ERROR] Could not find Temp contact folder!")
for f in folders:
print(f" Folder: {f.get('displayName')} -> {f['id'][:40]}...")
return
# Count remaining Temp contacts
print("\n[INFO] Counting remaining Temp contacts...")
remaining = count_temp_contacts(token)
print(f"[INFO] Remaining Temp contacts: {remaining}")
print(f"[INFO] Expected: ~3,888 (matches_with_extras)")
# Load operation results
print("\n--- OPERATION SUMMARIES ---")
for op_file, label in [
("D:/ClaudeTools/temp/bardach_op1_delete_exact.json", "Op1: Delete Exact Matches"),
("D:/ClaudeTools/temp/bardach_op2_move_unique.json", "Op2: Move Unique Contacts"),
("D:/ClaudeTools/temp/bardach_op3_delete_blank.json", "Op3: Delete Blank Contacts"),
]:
try:
with open(op_file, "r") as f:
r = json.load(f)
print(f"\n {label}:")
print(f" Total: {r['total']}")
print(f" Successes: {r['successes']}")
print(f" Failures: {r['failures']}")
if r.get("elapsed_seconds"):
print(f" Time: {r['elapsed_seconds']}s")
if r.get("method"):
print(f" Method: {r['method']}")
except FileNotFoundError:
print(f"\n {label}: [NOT FOUND] {op_file}")
except Exception as e:
print(f"\n {label}: [ERROR] {e}")
print(f"\n{'=' * 60}")
print(f"Remaining Temp contacts: {remaining}")
diff = remaining - 3888
if abs(diff) < 10:
print(f"[OK] Close to expected (~3,888), difference: {diff}")
else:
print(f"[WARNING] Difference from expected (3,888): {diff}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,103 +0,0 @@
"""Pull all deleted contacts from Barbara's mailbox."""
import subprocess, json
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER = "barbara@bardach.net"
# Get token
token_cmd = subprocess.run([
'curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
'-d', f'client_id={CLAUDE_APP}&client_secret={CLAUDE_SECRET}&scope=https://graph.microsoft.com/.default&grant_type=client_credentials'
], capture_output=True, text=True)
token = json.loads(token_cmd.stdout)['access_token']
# Page through all deleted contacts
url = (f"https://graph.microsoft.com/beta/users/{USER}/contacts"
f"?$filter=parentFolderId%20eq%20'deleteditems'"
f"&$top=100"
f"&$select=displayName,emailAddresses,companyName,lastModifiedDateTime")
all_deleted = []
page = 0
while url:
page += 1
cmd = ['curl', '-s', '-H', f'Authorization: Bearer {token}', url]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
if 'error' in data:
print(f"Error on page {page}: {data['error'].get('message','')[:200]}")
break
items = data.get('value', [])
all_deleted.extend(items)
url = data.get('@odata.nextLink')
if page % 5 == 0:
print(f" Page {page}: {len(all_deleted)} deleted contacts so far...")
if not items:
break
print(f"\nTotal deleted contacts found: {len(all_deleted)}")
# Analyze
from collections import Counter
if all_deleted:
# Save full data
with open('D:/ClaudeTools/temp/bardach_deleted_contacts.json', 'w') as f:
json.dump(all_deleted, f, indent=2)
# Name analysis
names = [c.get('displayName', '').strip() for c in all_deleted if c.get('displayName')]
name_counts = Counter([n.lower() for n in names])
dupes = {k: v for k, v in name_counts.items() if v > 1}
print(f"\nUnique names: {len(set(n.lower() for n in names))}")
print(f"Names with duplicates: {len(dupes)}")
# Check overlap with active contacts
active_file = 'D:/ClaudeTools/temp/bardach_contacts.json'
try:
with open(active_file) as f:
active = json.load(f)
active_names = set(c.get('displayName', '').strip().lower() for c in active if c.get('displayName'))
deleted_names = set(n.lower() for n in names)
overlap = active_names & deleted_names
only_deleted = deleted_names - active_names
print(f"\nActive contacts: {len(active_names)}")
print(f"Deleted contacts (unique names): {len(deleted_names)}")
print(f"Names in BOTH active and deleted: {len(overlap)}")
print(f"Names ONLY in deleted (not in active): {len(only_deleted)}")
if only_deleted:
print(f"\nSample of contacts only in Deleted Items (not in active contacts):")
for name in sorted(only_deleted)[:50]:
print(f" - {name}")
if len(only_deleted) > 50:
print(f" ... and {len(only_deleted) - 50} more")
except FileNotFoundError:
print("\n(Active contacts file not found for comparison)")
# Show date range
dates = [c.get('lastModifiedDateTime', '')[:10] for c in all_deleted if c.get('lastModifiedDateTime')]
if dates:
print(f"\nDate range of deleted contacts: {min(dates)} to {max(dates)}")
# Sample
print(f"\nFirst 30 deleted contacts:")
for c in all_deleted[:30]:
name = c.get('displayName', '(no name)')
emails = ', '.join([e.get('address', '') for e in c.get('emailAddresses', [])])
company = c.get('companyName', '')
modified = c.get('lastModifiedDateTime', '?')[:10]
detail = emails or company or ''
print(f" {modified} - {name}" + (f" ({detail})" if detail else ""))

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""Purge junk notes from 223 Bardach contacts in Microsoft 365."""
import json
import subprocess
import sys
import time
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
TOKEN_URL = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
def get_token():
"""Acquire OAuth2 token using client credentials."""
result = subprocess.run(
["curl", "-s", "-X", "POST", TOKEN_URL,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Token acquisition failed: {data}")
sys.exit(1)
return data["access_token"]
def patch_contact(token, contact_id, display_name):
"""PATCH a contact to clear personalNotes."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts/{contact_id}"
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "PATCH", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", '{"personalNotes": ""}'],
capture_output=True, text=True
)
status_code = result.stdout.strip()
return status_code
def main():
# Load analysis file
with open("D:/ClaudeTools/temp/bardach_notes_analysis.json", "r", encoding="utf-8") as f:
data = json.load(f)
# Collect all contact IDs to purge
contacts_to_purge = []
# iCloud junk notes (217)
for c in data["A_junk_boilerplate"]["icloud_warnings"]:
contacts_to_purge.append((c["id"], c["displayName"], "icloud_junk"))
# Empty/whitespace notes (6)
for c in data["A_junk_boilerplate"]["empty_whitespace"]:
contacts_to_purge.append((c["id"], c["displayName"], "empty"))
total = len(contacts_to_purge)
print(f"[INFO] Total contacts to purge: {total}")
print(f" - iCloud junk: {len(data['A_junk_boilerplate']['icloud_warnings'])}")
print(f" - Empty/whitespace: {len(data['A_junk_boilerplate']['empty_whitespace'])}")
print()
token = get_token()
print("[OK] Token acquired")
successes = 0
failures = 0
failed_contacts = []
for i, (cid, name, category) in enumerate(contacts_to_purge, 1):
# Re-acquire token every 200 operations
if i > 1 and (i - 1) % 200 == 0:
print(f"[INFO] Re-acquiring token at operation {i}...")
token = get_token()
print("[OK] Token re-acquired")
status = patch_contact(token, cid, name)
if status == "200":
successes += 1
else:
failures += 1
failed_contacts.append({"name": name, "id": cid, "status": status, "category": category})
# Progress every 50
if i % 50 == 0 or i == total:
print(f"[INFO] Progress: {i}/{total} | Successes: {successes} | Failures: {failures}")
# Small delay to avoid throttling
if i % 4 == 0:
time.sleep(0.1)
print()
print("=" * 60)
print(f"[DONE] Notes purge complete")
print(f" Total processed: {total}")
print(f" Successes: {successes}")
print(f" Failures: {failures}")
if failed_contacts:
print()
print("[WARNING] Failed contacts:")
for fc in failed_contacts:
print(f" - {fc['name']} (status={fc['status']}, category={fc['category']})")
if __name__ == "__main__":
main()

View File

@@ -1,123 +0,0 @@
"""
Bardach Contacts - Clear junk businessHomePage values from Microsoft 365.
Targets:
- All junk contacts with ms-outlook:// URLs
- Suspicious contact "Facebook" with value "Penfield@1964"
- Suspicious contact "Megan Billings" with value "John"
Does NOT touch:
- 10 legitimate websites
- 1 Facebook page link (DiBella's Restaurant)
"""
import json
import subprocess
import sys
import time
# --- Configuration ---
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
TOKEN_URL = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts"
ANALYSIS_FILE = "D:/ClaudeTools/temp/bardach_url_analysis.json"
TOKEN_REFRESH_INTERVAL = 200
def get_token():
"""Acquire OAuth2 token via client credentials."""
result = subprocess.run(
[
"curl", "-s", "-X", "POST", TOKEN_URL,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&scope={SCOPE}&grant_type=client_credentials",
],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to acquire token: {data}")
sys.exit(1)
print("[OK] Token acquired")
return data["access_token"]
def patch_contact(token, contact_id, display_name, index, total):
"""PATCH a single contact to clear businessHomePage."""
url = f"{GRAPH_BASE}/{contact_id}"
result = subprocess.run(
[
"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "PATCH", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", '{"businessHomePage": ""}',
],
capture_output=True, text=True
)
http_code = result.stdout.strip()
success = http_code in ("200", "204")
if not success:
print(f" [FAIL] {index}/{total} - {display_name} - HTTP {http_code}")
return success
def main():
# Load analysis data
with open(ANALYSIS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Build target list
targets = []
# 1) Junk contacts with ms-outlook:// pattern
for c in data.get("junk_contacts", []):
if c.get("url", "").startswith("ms-outlook://"):
targets.append((c["id"], c["displayName"]))
# 2) Suspicious contacts: "Facebook" and "Megan Billings"
for c in data.get("suspicious_contacts", []):
name = c.get("displayName", "")
if name == "Facebook" or name == "Megan Billings":
targets.append((c["id"], c["displayName"]))
total = len(targets)
print(f"[INFO] Contacts to patch: {total}")
if total == 0:
print("[WARNING] No targets found. Exiting.")
return
# Acquire initial token
token = get_token()
successes = 0
failures = 0
for i, (cid, name) in enumerate(targets, 1):
# Re-acquire token every TOKEN_REFRESH_INTERVAL operations
if i > 1 and (i - 1) % TOKEN_REFRESH_INTERVAL == 0:
print(f"[INFO] Re-acquiring token at operation {i}...")
token = get_token()
ok = patch_contact(token, cid, name, i, total)
if ok:
successes += 1
else:
failures += 1
# Progress every 50
if i % 50 == 0 or i == total:
print(f"[PROGRESS] {i}/{total} done (success={successes}, fail={failures})")
print()
print("=" * 50)
print(f"[SUMMARY] Total: {total} Success: {successes} Failures: {failures}")
print("=" * 50)
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,148 +0,0 @@
"""Check current state of Bardach Temp contacts folder and compare to previous snapshot."""
import subprocess, json, sys, os
from collections import Counter, defaultdict
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER = "barbara@bardach.net"
SELECT = ("id,displayName,givenName,surname,emailAddresses,"
"homePhones,businessPhones,companyName,jobTitle,"
"personalNotes,lastModifiedDateTime")
# --- 1. Get token ---
r = subprocess.run([
'curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
'-d', f'client_id={CLAUDE_APP}&client_secret={CLAUDE_SECRET}&scope=https://graph.microsoft.com/.default&grant_type=client_credentials'
], capture_output=True, text=True)
tok_data = json.loads(r.stdout)
if 'access_token' not in tok_data:
print(f"[ERROR] Token failed: {tok_data.get('error_description', tok_data)}")
sys.exit(1)
token = tok_data['access_token']
print("[OK] Token acquired")
# --- 2. Get Temp folder ID ---
r2 = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {token}',
f'https://graph.microsoft.com/v1.0/users/{USER}/contactFolders?$select=displayName,id'],
capture_output=True, text=True)
folders = json.loads(r2.stdout).get('value', [])
temp_id = None
for f in folders:
if f['displayName'] == 'Temp':
temp_id = f['id']
break
if not temp_id:
print("[ERROR] Temp folder not found. Folders:", [f['displayName'] for f in folders])
sys.exit(1)
print(f"[OK] Temp folder ID: {temp_id[:20]}...")
# --- 3. Pull ALL contacts with pagination ---
print("Pulling Temp contacts...")
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{temp_id}/contacts?$top=100&$select={SELECT}"
all_contacts = []
page = 0
while url:
page += 1
r = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {token}', url],
capture_output=True, text=True)
data = json.loads(r.stdout)
if 'error' in data:
print(f"[ERROR] Page {page}: {data['error'].get('message','')[:200]}")
break
items = data.get('value', [])
all_contacts.extend(items)
url = data.get('@odata.nextLink')
if page % 10 == 0:
print(f" Page {page}: {len(all_contacts)} contacts so far...")
if not items:
break
print(f"[OK] Total Temp contacts pulled: {len(all_contacts)} ({page} pages)")
# --- 4. Duplicate analysis ---
print(f"\n{'='*60}")
print("DUPLICATE ANALYSIS BY displayName")
print(f"{'='*60}")
name_groups = defaultdict(list)
no_name_contacts = []
for c in all_contacts:
name = (c.get('displayName') or '').strip()
if name:
name_groups[name.lower()].append(c)
else:
no_name_contacts.append(c)
unique_names = len(name_groups)
dupe_names = {k: v for k, v in name_groups.items() if len(v) > 1}
single_names = {k: v for k, v in name_groups.items() if len(v) == 1}
total_dupe_entries = sum(len(v) for v in dupe_names.values())
total_removable = sum(len(v) - 1 for v in dupe_names.values())
print(f"Total contacts: {len(all_contacts)}")
print(f"Contacts with no name: {len(no_name_contacts)}")
print(f"Unique display names: {unique_names}")
print(f" - Names appearing once: {len(single_names)}")
print(f" - Names with duplicates: {len(dupe_names)}")
print(f"Total entries in dupe groups: {total_dupe_entries}")
print(f"Removable duplicates: {total_removable}")
print(f"Estimated after dedup: {len(single_names) + len(dupe_names) + len(no_name_contacts)}")
# Duplicate distribution
dupe_dist = Counter(len(v) for v in dupe_names.values())
print(f"\nDuplicate distribution (how many names appear N times):")
for count, num_names in sorted(dupe_dist.items()):
print(f" {count}x: {num_names} names")
# Top 20 most duplicated
sorted_dupes = sorted(dupe_names.items(), key=lambda x: -len(x[1]))
print(f"\nTop 20 most duplicated names:")
print(f" {'Count':<6} {'Name':<35} {'Emails'}")
print(f" {'-'*5:<6} {'-'*34:<35} {'-'*30}")
for name, contacts in sorted_dupes[:20]:
emails = set()
for c in contacts:
for e in c.get('emailAddresses', []):
if e.get('address'):
emails.add(e['address'].lower())
email_str = ', '.join(sorted(emails)[:3]) if emails else '(no email)'
# Grab original-case name from first contact
orig_name = contacts[0].get('displayName', name)
print(f" {len(contacts):<6} {orig_name[:34]:<35} {email_str[:60]}")
# --- 5. Compare to previous snapshot ---
print(f"\n{'='*60}")
print("COMPARISON TO PREVIOUS SNAPSHOT")
print(f"{'='*60}")
prev_file = 'D:/ClaudeTools/temp/bardach_temp_all.json'
if os.path.exists(prev_file):
with open(prev_file, 'r') as f:
prev_contacts = json.load(f)
prev_count = len(prev_contacts)
curr_count = len(all_contacts)
diff = curr_count - prev_count
sign = '+' if diff > 0 else ''
print(f"Previous count: {prev_count}")
print(f"Current count: {curr_count}")
print(f"Difference: {sign}{diff}")
# Check IDs overlap
prev_ids = set(c.get('id') for c in prev_contacts)
curr_ids = set(c.get('id') for c in all_contacts)
removed = prev_ids - curr_ids
added = curr_ids - prev_ids
unchanged = prev_ids & curr_ids
print(f"\nBy contact ID:")
print(f" Still present (unchanged ID): {len(unchanged)}")
print(f" Removed since last snapshot: {len(removed)}")
print(f" New since last snapshot: {len(added)}")
else:
print(f"[WARNING] Previous file not found: {prev_file}")
print("No comparison available.")
print(f"\n[INFO] Script complete.")

File diff suppressed because it is too large Load Diff

View File

@@ -1,158 +0,0 @@
"""Pull all Temp contacts and analyze internal duplicates."""
import subprocess, json, sys
from collections import Counter, defaultdict
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER = "barbara@bardach.net"
SELECT = ("id,displayName,givenName,surname,emailAddresses,"
"homePhones,businessPhones,companyName,jobTitle,"
"personalNotes,homeAddress,businessAddress,lastModifiedDateTime")
# Get token
r = subprocess.run([
'curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
'-d', f'client_id={CLAUDE_APP}&client_secret={CLAUDE_SECRET}&scope=https://graph.microsoft.com/.default&grant_type=client_credentials'
], capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("[OK] Token acquired")
# Get Temp folder ID
r2 = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {token}',
f'https://graph.microsoft.com/v1.0/users/{USER}/contactFolders?$select=displayName,id'],
capture_output=True, text=True)
folders = json.loads(r2.stdout).get('value', [])
temp_id = next(f['id'] for f in folders if f['displayName'] == 'Temp')
# Pull all Temp contacts
print("Pulling Temp contacts...")
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{temp_id}/contacts?$top=100&$select={SELECT}"
all_contacts = []
page = 0
while url:
page += 1
r = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {token}', url],
capture_output=True, text=True)
data = json.loads(r.stdout)
if 'error' in data:
print(f"Error page {page}: {data['error'].get('message','')[:200]}")
break
items = data.get('value', [])
all_contacts.extend(items)
url = data.get('@odata.nextLink')
if page % 20 == 0:
print(f" Page {page}: {len(all_contacts)} contacts...")
if not items:
break
print(f"\nTotal Temp contacts pulled: {len(all_contacts)}")
# Save raw data
with open('D:/ClaudeTools/temp/bardach_temp_all.json', 'w') as f:
json.dump(all_contacts, f)
print("Saved to bardach_temp_all.json")
# Analyze duplicates by displayName
print(f"\n{'='*60}")
print("DUPLICATE ANALYSIS BY NAME")
print(f"{'='*60}")
name_groups = defaultdict(list)
for c in all_contacts:
name = (c.get('displayName') or '').strip().lower()
if name:
name_groups[name].append(c)
no_name = [c for c in all_contacts if not (c.get('displayName') or '').strip()]
unique_names = len(name_groups)
dupe_names = {k: v for k, v in name_groups.items() if len(v) > 1}
total_dupes = sum(len(v) - 1 for v in dupe_names.values())
print(f"Total contacts: {len(all_contacts)}")
print(f"Contacts with no name: {len(no_name)}")
print(f"Unique names: {unique_names}")
print(f"Names with duplicates: {len(dupe_names)}")
print(f"Total duplicate entries (removable): {total_dupes}")
print(f"Estimated after dedup: {unique_names + len(no_name)}")
# Distribution of duplicate counts
dupe_dist = Counter(len(v) for v in dupe_names.values())
print(f"\nDuplicate distribution:")
for count, num_names in sorted(dupe_dist.items()):
print(f" {count}x duplicated: {num_names} names")
# Top duplicated names
sorted_dupes = sorted(dupe_names.items(), key=lambda x: -len(x[1]))
print(f"\nTop 30 most duplicated:")
for name, contacts in sorted_dupes[:30]:
emails = set()
notes_count = 0
for c in contacts:
for e in c.get('emailAddresses', []):
if e.get('address'):
emails.add(e['address'].lower())
if (c.get('personalNotes') or '').strip():
notes_count += 1
email_str = ', '.join(list(emails)[:2]) if emails else '(no email)'
print(f" {len(contacts)}x - {name} | {email_str} | {notes_count} have notes")
# Sample notes to find cleanup patterns
print(f"\n{'='*60}")
print("NOTES CLEANUP PATTERNS")
print(f"{'='*60}")
# Collect all notes
all_notes = []
for c in all_contacts:
notes = (c.get('personalNotes') or '').strip()
if notes:
all_notes.append(notes)
print(f"Contacts with notes: {len(all_notes)}")
# Find common patterns
patterns_found = defaultdict(int)
for notes in all_notes:
lines = notes.split('\n')
for line in lines:
line = line.strip()
if 'read-only' in line.lower() and 'outlook' in line.lower():
patterns_found['read-only outlook warning'] += 1
elif 'tap the link' in line.lower():
patterns_found['tap the link instruction'] += 1
elif 'edit in outlook' in line.lower():
patterns_found['edit in outlook'] += 1
elif line.startswith('20') and len(line) > 10 and ('This contact' in line or 'read-only' in line.lower()):
patterns_found['dated read-only warning'] += 1
print(f"\nKnown junk patterns found:")
for pattern, count in sorted(patterns_found.items(), key=lambda x: -x[1]):
print(f" {pattern}: {count} occurrences")
# Show sample notes with the junk pattern
print(f"\nSample notes containing 'read-only' (first 5):")
shown = 0
for notes in all_notes:
if 'read-only' in notes.lower():
print(f" ---")
# Show first 300 chars
print(f" {notes[:300]}")
shown += 1
if shown >= 5:
break
# Show sample of notes that DON'T have the junk pattern (real data)
print(f"\nSample notes WITHOUT 'read-only' junk (first 5):")
shown = 0
for notes in all_notes:
if 'read-only' not in notes.lower() and len(notes) > 5:
print(f" ---")
print(f" {notes[:300]}")
shown += 1
if shown >= 5:
break

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,349 +0,0 @@
#!/usr/bin/env python3
"""Analyze website/URL fields for all Bardach contacts in Microsoft 365."""
import json
import subprocess
import sys
import time
from urllib.parse import urlparse
from collections import defaultdict
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
TOKEN_URL = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
JUNK_PATTERNS = [
"ms-outlook://",
"linkedin.com/in/",
"linkedin.com/company/",
"outlook.live.com",
"profile.live.com",
"people.live.com",
"social.microsoft.com",
"contact.skype.com",
"d.docs.live.net",
"storage.live.com",
"onedrive.live.com",
"1drv.ms",
"facebook.com",
"twitter.com",
"x.com",
"plus.google.com",
"instagram.com",
"myspace.com",
"flickr.com",
"foursquare.com",
"about.me",
"gravatar.com",
"apis.live.net",
"cid-",
"skype:",
]
# Social media domains that M365 auto-links
SOCIAL_DOMAINS = {
"linkedin.com", "www.linkedin.com",
"facebook.com", "www.facebook.com", "m.facebook.com",
"twitter.com", "www.twitter.com",
"x.com", "www.x.com",
"instagram.com", "www.instagram.com",
"plus.google.com",
"myspace.com", "www.myspace.com",
"flickr.com", "www.flickr.com",
"foursquare.com", "www.foursquare.com",
"about.me",
"gravatar.com", "www.gravatar.com",
}
# Microsoft internal domains
MS_DOMAINS = {
"outlook.live.com", "profile.live.com", "people.live.com",
"social.microsoft.com", "contact.skype.com",
"d.docs.live.net", "storage.live.com", "onedrive.live.com",
"1drv.ms", "apis.live.net",
}
def get_token():
result = subprocess.run(
["curl", "-s", "-X", "POST", TOKEN_URL,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Token acquisition failed: {data}")
sys.exit(1)
return data["access_token"]
def fetch_all_contacts(token):
"""Fetch all contacts with pagination."""
contacts = []
select = "id,displayName,businessHomePage,emailAddresses,personalNotes,companyName"
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contacts?$select={select}&$top=100"
call_count = 0
while url:
call_count += 1
# Re-acquire token every 500 calls
if call_count > 1 and (call_count - 1) % 500 == 0:
print(f"[INFO] Re-acquiring token at call {call_count}...")
token = get_token()
print("[OK] Token re-acquired")
result = subprocess.run(
["curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "value" not in data:
print(f"[ERROR] Unexpected response: {json.dumps(data)[:300]}")
break
batch = data["value"]
contacts.extend(batch)
if len(contacts) % 500 == 0 or len(contacts) % 100 < len(batch):
if len(contacts) % 500 < 100:
print(f"[INFO] Fetched {len(contacts)} contacts so far...")
url = data.get("@odata.nextLink")
if url:
time.sleep(0.05)
return contacts, token
def categorize_url(url_str):
"""Categorize a URL as junk, legitimate, or suspicious."""
if not url_str or not url_str.strip():
return "suspicious", "empty"
url_lower = url_str.lower().strip()
# Check for obviously malformed
if len(url_lower) < 4:
return "suspicious", "too_short"
# Check junk patterns
for pattern in JUNK_PATTERNS:
if pattern in url_lower:
return "junk", pattern
# Try parsing
try:
# Add scheme if missing
parse_url = url_lower
if not parse_url.startswith("http"):
parse_url = "https://" + parse_url
parsed = urlparse(parse_url)
domain = parsed.netloc.lower()
# Check social/MS domains
if domain in SOCIAL_DOMAINS:
return "junk", domain
if domain in MS_DOMAINS:
return "junk", domain
# Check for no real domain
if not domain or "." not in domain:
return "suspicious", "no_valid_domain"
return "legitimate", domain
except Exception:
return "suspicious", "parse_error"
def extract_domain(url_str):
"""Extract domain from URL."""
if not url_str:
return "unknown"
url_lower = url_str.lower().strip()
if not url_lower.startswith("http"):
url_lower = "https://" + url_lower
try:
parsed = urlparse(url_lower)
return parsed.netloc or "unknown"
except Exception:
return "unknown"
def main():
print("=" * 70)
print("BARDACH CONTACTS - WEBSITE/URL FIELD ANALYSIS")
print("=" * 70)
print()
token = get_token()
print("[OK] Token acquired")
print("[INFO] Fetching all contacts...")
contacts, token = fetch_all_contacts(token)
total = len(contacts)
print(f"[OK] Fetched {total} total contacts")
print()
# Analyze businessHomePage
contacts_with_url = []
contacts_without_url = []
for c in contacts:
bhp = c.get("businessHomePage")
if bhp and bhp.strip():
contacts_with_url.append(c)
else:
contacts_without_url.append(c)
print(f"[INFO] Contacts with businessHomePage: {len(contacts_with_url)}")
print(f"[INFO] Contacts without businessHomePage: {len(contacts_without_url)}")
print()
# Categorize
junk_contacts = []
legitimate_contacts = []
suspicious_contacts = []
linkedin_profiles = []
facebook_profiles = []
domain_counts = defaultdict(int)
junk_by_pattern = defaultdict(list)
for c in contacts_with_url:
url = c.get("businessHomePage", "").strip()
category, detail = categorize_url(url)
domain = extract_domain(url)
domain_counts[domain] += 1
entry = {
"id": c["id"],
"displayName": c.get("displayName", ""),
"url": url,
"companyName": c.get("companyName", ""),
"category": category,
"detail": detail,
"domain": domain,
}
if category == "junk":
junk_contacts.append(entry)
junk_by_pattern[detail].append(entry)
# Cross-reference LinkedIn
url_lower = url.lower()
if "linkedin.com" in url_lower:
linkedin_profiles.append(entry)
elif "facebook.com" in url_lower:
facebook_profiles.append(entry)
elif category == "legitimate":
legitimate_contacts.append(entry)
else:
suspicious_contacts.append(entry)
# Print report
print("=" * 70)
print("RESULTS SUMMARY")
print("=" * 70)
print(f"Total contacts: {total}")
print(f"Contacts with businessHomePage: {len(contacts_with_url)}")
print(f" - Junk (auto-inserted): {len(junk_contacts)}")
print(f" - Legitimate websites: {len(legitimate_contacts)}")
print(f" - Suspicious/broken: {len(suspicious_contacts)}")
print()
# Junk URLs grouped by pattern
print("=" * 70)
print("JUNK URLs BY PATTERN")
print("=" * 70)
for pattern, entries in sorted(junk_by_pattern.items(), key=lambda x: -len(x[1])):
print(f"\n Pattern: {pattern} ({len(entries)} contacts)")
for e in entries[:5]:
print(f" - {e['displayName']}: {e['url']}")
if len(entries) > 5:
print(f" ... and {len(entries) - 5} more")
# Suspicious URLs
print()
print("=" * 70)
print("SUSPICIOUS/BROKEN URLs")
print("=" * 70)
if suspicious_contacts:
for e in suspicious_contacts:
print(f" - {e['displayName']}: \"{e['url']}\" (reason: {e['detail']})")
else:
print(" None found")
# Legitimate URLs (first 30)
print()
print("=" * 70)
print("LEGITIMATE URLs (first 30)")
print("=" * 70)
for e in legitimate_contacts[:30]:
company = f" [{e['companyName']}]" if e['companyName'] else ""
print(f" - {e['displayName']}{company}: {e['url']}")
if len(legitimate_contacts) > 30:
print(f" ... and {len(legitimate_contacts) - 30} more")
# Domain distribution
print()
print("=" * 70)
print("DOMAIN DISTRIBUTION")
print("=" * 70)
for domain, count in sorted(domain_counts.items(), key=lambda x: -x[1]):
print(f" {domain}: {count}")
# LinkedIn cross-reference
print()
print("=" * 70)
print(f"LINKEDIN PROFILES ({len(linkedin_profiles)})")
print("=" * 70)
for e in linkedin_profiles:
print(f" - {e['displayName']}: {e['url']}")
# Facebook cross-reference
print()
print("=" * 70)
print(f"FACEBOOK PROFILES ({len(facebook_profiles)})")
print("=" * 70)
for e in facebook_profiles:
print(f" - {e['displayName']}: {e['url']}")
# Save results
results = {
"summary": {
"total_contacts": total,
"contacts_with_url": len(contacts_with_url),
"contacts_without_url": len(contacts_without_url),
"junk_count": len(junk_contacts),
"legitimate_count": len(legitimate_contacts),
"suspicious_count": len(suspicious_contacts),
"linkedin_count": len(linkedin_profiles),
"facebook_count": len(facebook_profiles),
},
"junk_contacts": junk_contacts,
"legitimate_contacts": legitimate_contacts,
"suspicious_contacts": suspicious_contacts,
"linkedin_profiles": linkedin_profiles,
"facebook_profiles": facebook_profiles,
"domain_distribution": dict(sorted(domain_counts.items(), key=lambda x: -x[1])),
"junk_by_pattern": {k: [{"displayName": e["displayName"], "url": e["url"]} for e in v]
for k, v in sorted(junk_by_pattern.items(), key=lambda x: -len(x[1]))},
}
out_path = "D:/ClaudeTools/temp/bardach_url_analysis.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n[OK] Results saved to {out_path}")
if __name__ == "__main__":
main()

View File

@@ -1,51 +0,0 @@
# Check Lesley's email activity since disable
$ErrorActionPreference = "Stop"
$lesleyUPN = "lesley@bgbuildersllc.com"
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
$startDate = (Get-Date).AddDays(-3)
$endDate = Get-Date
Write-Output "=== MAILBOX STATUS ==="
$mbx = Get-Mailbox -Identity $lesleyUPN
$stats = Get-MailboxStatistics -Identity $lesleyUPN
Write-Output "Type: $($mbx.RecipientTypeDetails)"
Write-Output "LitigationHold: $($mbx.LitigationHoldEnabled)"
Write-Output "ItemCount: $($stats.ItemCount)"
Write-Output "TotalSize: $($stats.TotalItemSize)"
Write-Output "`n=== SENT MESSAGES (last 3 days) ==="
$sent = Get-MessageTraceV2 -SenderAddress $lesleyUPN -StartDate $startDate -EndDate $endDate
if ($sent) {
$sent | Format-Table Received,RecipientAddress,Subject -AutoSize
} else {
Write-Output "None found"
}
Write-Output "`n=== RECEIVED MESSAGES (last 3 days) ==="
$recv = Get-MessageTraceV2 -RecipientAddress $lesleyUPN -StartDate $startDate -EndDate $endDate
if ($recv) {
$recv | Select-Object -First 20 | Format-Table Received,SenderAddress,Subject -AutoSize
} else {
Write-Output "None found"
}
Write-Output "`n=== INBOX RULES ==="
$rules = Get-InboxRule -Mailbox $lesleyUPN
if ($rules) {
$rules | Format-Table Name,Enabled,Description -AutoSize
} else {
Write-Output "No inbox rules"
}
Write-Output "`n=== FORWARDING CONFIG ==="
Write-Output "ForwardingAddress: $($mbx.ForwardingAddress)"
Write-Output "ForwardingSmtpAddress: $($mbx.ForwardingSmtpAddress)"
Write-Output "DeliverToMailboxAndForward: $($mbx.DeliverToMailboxAndForward)"
Write-Output "`n=== FOLDER ITEM COUNTS ==="
Get-MailboxFolderStatistics -Identity $lesleyUPN | Where-Object { $_.ItemsInFolder -gt 0 } | Sort-Object ItemsInFolder -Descending | Select-Object -First 15 | Format-Table Name,FolderType,ItemsInFolder,FolderSize -AutoSize
Disconnect-ExchangeOnline -Confirm:$false

View File

@@ -1,52 +0,0 @@
# Update MFA phone number for Lesley Roth @ BG Builders
$ErrorActionPreference = "Stop"
$lesleyUPN = "lesley@bgbuildersllc.com"
$newPhone = "+1 4804954511"
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Connect-MgGraph -TenantId $tenantId -Scopes 'UserAuthenticationMethod.ReadWrite.All','User.ReadWrite.All' -NoWelcome
Write-Output "=== Current Auth Methods for Lesley ==="
$methods = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$lesleyUPN/authentication/phoneMethods"
if ($methods.value.Count -gt 0) {
foreach ($m in $methods.value) {
Write-Output " ID: $($m.id) | Type: $($m.phoneType) | Number: $($m.phoneNumber)"
}
} else {
Write-Output " No phone methods registered"
}
Write-Output "`n=== Updating MFA Phone ==="
# Phone method ID for mobile is always "3179e48a-750b-4051-897c-87b9720928f7"
$mobileMethodId = "3179e48a-750b-4051-897c-87b9720928f7"
try {
# Try to update existing mobile phone method
Invoke-MgGraphRequest -Method PUT -Uri "https://graph.microsoft.com/v1.0/users/$lesleyUPN/authentication/phoneMethods/$mobileMethodId" -Body @{
phoneNumber = $newPhone
phoneType = "mobile"
}
Write-Output "[OK] Mobile phone updated to $newPhone"
} catch {
Write-Output "[INFO] PUT failed, trying POST to create new method..."
try {
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/users/$lesleyUPN/authentication/phoneMethods" -Body @{
phoneNumber = $newPhone
phoneType = "mobile"
}
Write-Output "[OK] Mobile phone created: $newPhone"
} catch {
Write-Output "[ERROR] Failed: $_"
}
}
Write-Output "`n=== Verify Updated Methods ==="
$methods = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$lesleyUPN/authentication/phoneMethods"
foreach ($m in $methods.value) {
Write-Output " ID: $($m.id) | Type: $($m.phoneType) | Number: $($m.phoneNumber)"
}
Disconnect-MgGraph

View File

@@ -1,26 +0,0 @@
# Update MFA phone number for Lesley Roth @ BG Builders
$ErrorActionPreference = "Stop"
$lesleyUPN = "lesley@bgbuildersllc.com"
$newPhone = "+1 4804954511"
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
Import-Module Microsoft.Graph.Authentication
Connect-MgGraph -TenantId $tenantId -Scopes 'UserAuthenticationMethod.ReadWrite.All' -NoWelcome
$mobileMethodId = "3179e48a-750b-4051-897c-87b9720928f7"
Write-Output "Current: +1 4802299138"
Write-Output "Changing to: $newPhone"
$body = @{ phoneNumber = $newPhone; phoneType = "mobile" } | ConvertTo-Json
Invoke-MgGraphRequest -Method PUT -Uri "https://graph.microsoft.com/v1.0/users/$lesleyUPN/authentication/phoneMethods/$mobileMethodId" -Body $body -ContentType "application/json"
Write-Output "[OK] Phone updated"
# Verify
$methods = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$lesleyUPN/authentication/phoneMethods"
foreach ($m in $methods.value) {
Write-Output "Verified: $($m.phoneType) = $($m.phoneNumber)"
}
Disconnect-MgGraph

View File

@@ -1,62 +0,0 @@
#!/bin/bash
################################################################################
# Pavon Cleanup Status Checker
# Quick script to check deletion progress
################################################################################
echo "Checking Pavon cleanup status..."
echo ""
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@172.16.1.33 '
echo "==================================================================="
echo "PAVON CLEANUP STATUS"
echo "==================================================================="
echo ""
# Check if cleanup is running
if ps aux | grep -q "[p]avon_cleanup.sh"; then
echo "[RUNNING] Cleanup process is active"
echo ""
# Count deleted files
deleted=$(grep -c "Deleted:" /root/cleanup_logs/cleanup_*.log 2>/dev/null | awk "{sum+=\$1} END {print sum}")
echo "Files deleted: $deleted / 184,120"
percent=$(echo "scale=1; $deleted * 100 / 184120" | bc)
echo "Progress: $percent%"
echo ""
# Show current disk usage
echo "Current disk usage:"
df -h /mnt/user | tail -1
echo ""
# Show recent activity
echo "Recent deletions:"
tail -5 $(ls -t /root/cleanup_logs/cleanup_*.log | head -1)
else
echo "[COMPLETE] Cleanup process finished"
echo ""
# Show final results
latest_log=$(ls -t /root/cleanup_logs/cleanup_*.log | head -1)
echo "Final log: $latest_log"
echo ""
# Count total deleted
deleted=$(grep -c "Deleted:" $latest_log 2>/dev/null)
echo "Total files deleted: $deleted"
echo ""
# Show final disk usage
echo "Final disk usage:"
df -h /mnt/user | tail -1
echo ""
# Check for errors
errors=$(grep -c "Failed:" $latest_log 2>/dev/null)
echo "Failed deletions: $errors"
fi
echo "==================================================================="
' 2>&1

View File

@@ -1 +0,0 @@
Access to this CIPP API endpoint is not allowed, the API Client does not have the required permission

View File

@@ -1,43 +0,0 @@
import json
# Load sign-in data
signins = json.load(open('D:/ClaudeTools/temp/vwp_signins_raw.json')).get('value', [])
# Load existing results
try:
results = json.load(open('D:/ClaudeTools/temp/vwp_bec_results.json'))
except:
results = {}
# Add sign-in data
results['signins'] = signins
# Add sign-in summary
ips = {}
locations = {}
failed = 0
risky = 0
for s in signins:
ip = s.get('ipAddress', 'N/A')
loc = s.get('location', {})
country = loc.get('countryOrRegion', '?')
loc_str = f"{loc.get('city','?')}, {loc.get('state','?')}, {country}"
ips[ip] = ips.get(ip, 0) + 1
locations[loc_str] = locations.get(loc_str, 0) + 1
if s.get('status', {}).get('errorCode', 0) != 0:
failed += 1
if s.get('riskLevelDuringSignIn', 'none') not in ('none', 'low', None, ''):
risky += 1
results['signin_summary'] = {
'total': len(signins),
'failed': failed,
'risky': risky,
'unique_ips': len(ips),
'ips': ips,
'locations': locations
}
with open('D:/ClaudeTools/temp/vwp_bec_results.json', 'w') as f:
json.dump(results, f, indent=2, default=str)
print('Results compiled and saved.')

View File

@@ -1,65 +0,0 @@
$ErrorActionPreference = 'SilentlyContinue'
$SID = 'S-1-12-1-4150293861-1139320743-1956584882-216650436' # kristinsteen
$wp2x64 = 'C:\Program Files\Datto\Workplace2\SmartBadge\DattoSmartBadgeShim_x64.dll'
$wp2x86 = 'C:\Program Files\Datto\Workplace2\SmartBadge\DattoSmartBadgeShim_x86.dll'
# --- Guard: Excel must be closed (it rewrites per-user add-in state on exit) ---
$xl = Get-Process EXCEL -ErrorAction SilentlyContinue
if ($xl) { Write-Output "[ABORT] EXCEL.EXE is running (pid $($xl.Id)). Close Excel before running the per-user fix."; exit 2 }
Write-Output "=== MACHINE-WIDE: remove v8 leftovers (match EVO-X1) ==="
# Drop the Workplace Desktop CLSID {2B96EDC1} (x64 + WOW64)
foreach ($k in @(
'HKLM:\Software\Classes\CLSID\{2B96EDC1-FDF3-47E1-B177-F205E7B98DF4}',
'HKLM:\Software\WOW6432Node\Classes\CLSID\{2B96EDC1-FDF3-47E1-B177-F205E7B98DF4}')) {
if (Test-Path $k) { Remove-Item $k -Recurse -Force; Write-Output " removed $k" } else { Write-Output " absent $k" }
}
# Drop the non-_CC Datto.SmartBadgeShim add-in keys (EVO-X1 only has _CC)
foreach ($k in @(
'HKLM:\Software\Microsoft\Office\Excel\Addins\Datto.SmartBadgeShim',
'HKLM:\Software\WOW6432Node\Microsoft\Office\Excel\Addins\Datto.SmartBadgeShim',
'HKLM:\Software\Microsoft\Office\Word\Addins\Datto.SmartBadgeShim',
'HKLM:\Software\Microsoft\Office\PowerPoint\Addins\Datto.SmartBadgeShim')) {
if (Test-Path $k) { Remove-Item $k -Recurse -Force; Write-Output " removed $k" } else { Write-Output " absent $k" }
}
Write-Output ""
Write-Output "=== MACHINE-WIDE: verify _CC CLSID -> Workplace2 DLLs ==="
$cc = '{3C639243-95A2-400D-B4B4-4384DA7F61D3}'
foreach ($pair in @(@("HKLM:\Software\Classes\CLSID\$cc\InprocServer32",$wp2x64), @("HKLM:\Software\WOW6432Node\Classes\CLSID\$cc\InprocServer32",$wp2x86))) {
$path = $pair[0]; $want = $pair[1]
$cur = (Get-Item $path -ErrorAction SilentlyContinue)
$val = if ($cur) { $cur.GetValue('') } else { $null }
if ($val -ne $want) {
if (-not (Test-Path $path)) { New-Item $path -Force | Out-Null }
Set-ItemProperty $path -Name '(default)' -Value $want
Set-ItemProperty $path -Name 'ThreadingModel' -Value 'Apartment'
Write-Output " set $path -> $want"
} else { Write-Output " ok $path -> $val" }
}
Write-Output ""
Write-Output "=== PER-USER ($SID): clear stuck disabled state ==="
$xlAddins = "Registry::HKEY_USERS\$SID\Software\Microsoft\Office\Excel\Addins"
# Remove non-_CC leftover in user hive (match EVO-X1 = no per-user Datto entries)
if (Test-Path "$xlAddins\Datto.SmartBadgeShim") { Remove-Item "$xlAddins\Datto.SmartBadgeShim" -Recurse -Force; Write-Output " removed HKCU Datto.SmartBadgeShim" }
# Reset _CC per-user override to 3 (Excel had set it to 2 = disabled)
if (Test-Path "$xlAddins\Datto.SmartBadgeShim_CC") {
Set-ItemProperty "$xlAddins\Datto.SmartBadgeShim_CC" -Name 'LoadBehavior' -Value 3 -Type DWord
Write-Output " set HKCU Datto.SmartBadgeShim_CC LoadBehavior=3"
}
# Clear Excel Resiliency DisabledItems + crash records
$res = "Registry::HKEY_USERS\$SID\Software\Microsoft\Office\16.0\Excel\Resiliency"
foreach ($sub in @('DisabledItems','CrashingAddinList','DocumentRecovery')) {
if (Test-Path "$res\$sub") {
$i = Get-Item "$res\$sub"
$i.GetValueNames() | ForEach-Object { Remove-ItemProperty "$res\$sub" -Name $_ -Force }
Write-Output " cleared values under Resiliency\$sub"
}
}
# Keep add-in protected from auto-disable
$dnd = "$res\DoNotDisableAddinList"
if (-not (Test-Path $dnd)) { New-Item $dnd -Force | Out-Null }
Set-ItemProperty $dnd -Name 'Datto.SmartBadgeShim_CC' -Value 1 -Type DWord
Write-Output " ensured DoNotDisableAddinList Datto.SmartBadgeShim_CC=1"
Write-Output "=== FIX COMPLETE ==="

View File

@@ -1,19 +0,0 @@
$ErrorActionPreference = 'Stop'
$exe = 'C:\Users\kristinsteen\Downloads\DattoWorkplaceSetup_v10.5.3.4.exe'
if (-not (Test-Path $exe)) { Write-Output "[ERROR] installer missing: $exe"; exit 1 }
# Safety: confirm v8 is actually gone before installing v10
$v8 = Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like '*Workplace Desktop*' }
if ($v8) { Write-Output "[ABORT] Workplace Desktop v8 still present - not installing yet:"; $v8 | ForEach-Object { Write-Output (" {0} v{1}" -f $_.DisplayName,$_.DisplayVersion) }; exit 2 }
Write-Output "[INFO] v8 confirmed absent. Starting silent install of v10..."
$p = Start-Process -FilePath $exe -ArgumentList '/install','/quiet','/norestart' -Wait -PassThru
Write-Output ("[INFO] Installer exit code: {0}" -f $p.ExitCode)
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { Write-Output "[WARNING] non-zero/non-3010 exit; check Bootstrap log" }
Start-Sleep -Seconds 8
Write-Output "=== Post-install product check ==="
Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like '*Workplace*' } | ForEach-Object { Write-Output (" {0} v{1} {2}" -f $_.DisplayName,$_.DisplayVersion,$_.InstallLocation) }
Get-ChildItem 'C:\Program Files\Datto' -ErrorAction SilentlyContinue | ForEach-Object { Write-Output (" folder: {0}" -f $_.Name) }
Get-ChildItem 'C:\Program Files\Datto\Workplace2\SmartBadge' -Filter 'DattoSmartBadgeShim*.dll' -ErrorAction SilentlyContinue | ForEach-Object { Write-Output (" dll: {0}" -f $_.FullName) }
Write-Output "=== END ==="

View File

@@ -1,27 +0,0 @@
$ErrorActionPreference = 'SilentlyContinue'
Write-Output "=== Locate v10 installer (all user Downloads + common) ==="
$roots = @("C:\Users\kristinsteen\Downloads","C:\Users\Public\Downloads")
Get-ChildItem 'C:\Users\*\Downloads' -Filter 'DattoWorkplace*' -ErrorAction SilentlyContinue | ForEach-Object {
Write-Output (" {0} ({1:N1} MB, modified {2})" -f $_.FullName, ($_.Length/1MB), $_.LastWriteTime)
}
Get-ChildItem 'C:\Users\*\Downloads' -Filter '*Workplace*Setup*' -ErrorAction SilentlyContinue | ForEach-Object {
Write-Output (" {0} ({1:N1} MB, modified {2})" -f $_.FullName, ($_.Length/1MB), $_.LastWriteTime)
}
Write-Output ""
Write-Output "=== Datto Workplace Desktop uninstall strings ==="
$paths = @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')
foreach ($p in $paths) {
Get-ItemProperty $p -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like '*Workplace*' } | ForEach-Object {
Write-Output (" Name: {0} v{1}" -f $_.DisplayName, $_.DisplayVersion)
Write-Output (" Key: {0}" -f $_.PSChildName)
Write-Output (" Uninstall: {0}" -f $_.UninstallString)
Write-Output (" QuietUninstall: {0}" -f $_.QuietUninstallString)
}
}
Write-Output ""
Write-Output "=== Datto Workplace Desktop process/service state ==="
Get-Process WorkplaceDesktop -ErrorAction SilentlyContinue | ForEach-Object { Write-Output (" proc WorkplaceDesktop pid {0}" -f $_.Id) }
Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like '*Workplace*' } | ForEach-Object { Write-Output (" svc {0} [{1}]" -f $_.Name, $_.Status) }
Write-Output "=== END ==="

View File

@@ -1,73 +0,0 @@
$ErrorActionPreference = 'SilentlyContinue'
Write-Output "=== HOST ==="
Write-Output $env:COMPUTERNAME
Write-Output "=== LOGGED-ON USER ==="
query user 2>$null
Write-Output ""
Write-Output "=== INSTALLED DATTO/WORKPLACE PRODUCTS (uninstall keys) ==="
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
foreach ($p in $paths) {
Get-ItemProperty $p -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like '*Datto*' -or $_.DisplayName -like '*Workplace*' } |
ForEach-Object { Write-Output (" {0} | v{1} | {2}" -f $_.DisplayName, $_.DisplayVersion, $_.InstallLocation) }
}
Write-Output ""
Write-Output "=== DATTO PROGRAM FOLDERS ==="
Get-ChildItem 'C:\Program Files\Datto' -ErrorAction SilentlyContinue | ForEach-Object { Write-Output (" {0} (modified {1})" -f $_.Name, $_.LastWriteTime) }
Write-Output "--- SmartBadge DLLs present ---"
Get-ChildItem 'C:\Program Files\Datto' -Recurse -Filter 'DattoSmartBadgeShim*.dll' -ErrorAction SilentlyContinue | ForEach-Object { Write-Output (" {0}" -f $_.FullName) }
Write-Output ""
Write-Output "=== DATTO WORKPLACE SERVICES / PROCESSES ==="
Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like '*Datto*' -or $_.DisplayName -like '*Workplace*' } | ForEach-Object { Write-Output (" svc {0} [{1}] {2}" -f $_.Name, $_.Status, $_.DisplayName) }
Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like '*Workplace*' -or $_.ProcessName -like '*Datto*' } | ForEach-Object { Write-Output (" proc {0} (pid {1}) {2}" -f $_.ProcessName, $_.Id, $_.Path) }
Write-Output ""
Write-Output "=== HKLM Excel Addins (Datto) ==="
foreach ($base in @('HKLM:\Software\Microsoft\Office\Excel\Addins','HKLM:\Software\WOW6432Node\Microsoft\Office\Excel\Addins')) {
Write-Output "[$base]"
Get-ChildItem $base -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like '*Datto*' } | ForEach-Object {
Write-Output (" {0} LoadBehavior={1}" -f $_.PSChildName, (Get-ItemProperty $_.PSPath).LoadBehavior)
}
}
Write-Output ""
Write-Output "=== CLSID InprocServer32 (SmartBadge shims) ==="
foreach ($clsid in @('{2B96EDC1-FDF3-47E1-B177-F205E7B98DF4}','{3C639243-95A2-400D-B4B4-4384DA7F61D3}')) {
foreach ($base in @("HKLM:\Software\Classes\CLSID\$clsid\InprocServer32","HKLM:\Software\WOW6432Node\Classes\CLSID\$clsid\InprocServer32")) {
$item = Get-Item $base -ErrorAction SilentlyContinue
if ($item) {
$def = $item.GetValue('')
$tm = $item.GetValue('ThreadingModel')
Write-Output (" {0}`n -> {1} [TM={2}]" -f $base, $def, $tm)
} else {
Write-Output (" {0}`n -> <MISSING>" -f $base)
}
}
}
Write-Output ""
Write-Output "=== Active user hive: Excel addin LoadBehavior + Resiliency ==="
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'S-1-12-1-|S-1-5-21-' -and $_.Name -notmatch '_Classes$' } | ForEach-Object {
$sid = $_.PSChildName
$ua = "Registry::HKEY_USERS\$sid\Software\Microsoft\Office\Excel\Addins"
if (Test-Path $ua) {
Get-ChildItem $ua -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like '*Datto*' } | ForEach-Object {
Write-Output (" [$sid] HKCU addin {0} LoadBehavior={1}" -f $_.PSChildName, (Get-ItemProperty $_.PSPath).LoadBehavior)
}
}
$rb = "Registry::HKEY_USERS\$sid\Software\Microsoft\Office\16.0\Excel\Resiliency"
if (Test-Path "$rb\DoNotDisableAddinList") {
(Get-ItemProperty "$rb\DoNotDisableAddinList").PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object { Write-Output (" [$sid] DoNotDisable {0}={1}" -f $_.Name, $_.Value) }
}
if (Test-Path "$rb\DisabledItems") {
$di = Get-Item "$rb\DisabledItems"
if ($di.ValueCount -gt 0) { Write-Output (" [$sid] DisabledItems has {0} entries (Excel has disabled an add-in)" -f $di.ValueCount) }
}
}
Write-Output "=== END RECON ==="

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
"""Generate backup codes for office@lonestarelectrical.net so Kyla can bypass 2FA enrollment block"""
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = [
'https://www.googleapis.com/auth/admin.directory.user',
'https://www.googleapis.com/auth/admin.directory.user.security',
]
creds = service_account.Credentials.from_service_account_file(
'temp/acg-msp-access-8f72339997e5.json', scopes=SCOPES
)
delegated = creds.with_subject('sysadmin@lonestarelectrical.net')
service = build('admin', 'directory_v1', credentials=delegated)
user_email = 'office@lonestarelectrical.net'
# Check current 2SV status
print(f"=== {user_email} 2SV Status ===")
user = service.users().get(userKey=user_email).execute()
print(f"2SV Enrolled: {user.get('isEnrolledIn2Sv', False)}")
print(f"2SV Enforced: {user.get('isEnforcedIn2Sv', False)}")
# Generate backup verification codes
print(f"\n=== Generating Backup Codes ===")
try:
codes = service.verificationCodes().generate(userKey=user_email).execute()
print("[OK] Backup codes generated")
except Exception as e:
print(f"[INFO] Generate returned: {e}")
# List the codes
try:
result = service.verificationCodes().list(userKey=user_email).execute()
backup_codes = result.get('items', [])
if backup_codes:
print(f"\nBackup codes for Kyla to use at login:")
for code in backup_codes:
status = code.get('etag', '')
print(f" {code.get('verificationCode', 'N/A')}")
print(f"\nInstructions for Kyla:")
print(f" 1. Go to https://accounts.google.com")
print(f" 2. Enter email: {user_email}")
print(f" 3. Enter the temp password we set")
print(f" 4. When prompted for 2FA, click 'Try another way'")
print(f" 5. Select 'Enter a backup code'")
print(f" 6. Use one of the codes above")
print(f" 7. Once logged in, go to Security > 2-Step Verification to set up her phone")
else:
print("[WARNING] No codes returned")
except Exception as e:
print(f"[ERROR] Could not list codes: {e}")

View File

@@ -1,60 +0,0 @@
"""Reset password for office@lonestarelectrical.net so Kyla can login and set up MFA"""
import secrets
import string
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = [
'https://www.googleapis.com/auth/admin.directory.user',
'https://www.googleapis.com/auth/admin.directory.user.security',
]
creds = service_account.Credentials.from_service_account_file(
'temp/acg-msp-access-8f72339997e5.json', scopes=SCOPES
)
delegated = creds.with_subject('sysadmin@lonestarelectrical.net')
service = build('admin', 'directory_v1', credentials=delegated)
user_email = 'office@lonestarelectrical.net'
# Check current user status
print(f"=== Checking {user_email} ===")
try:
user = service.users().get(userKey=user_email).execute()
print(f"Name: {user.get('name', {}).get('fullName', 'N/A')}")
print(f"Suspended: {user.get('suspended', 'N/A')}")
print(f"Archived: {user.get('archived', 'N/A')}")
print(f"2FA Enrolled: {user.get('isEnrolledIn2Sv', 'N/A')}")
print(f"2FA Enforced: {user.get('isEnforcedIn2Sv', 'N/A')}")
print(f"Last Login: {user.get('lastLoginTime', 'N/A')}")
print(f"Creation: {user.get('creationTime', 'N/A')}")
except Exception as e:
print(f"[ERROR] Could not get user: {e}")
exit(1)
# Generate a temp password
alphabet = string.ascii_letters + string.digits + "!@#$"
temp_pass = ''.join(secrets.choice(alphabet) for _ in range(16))
# Reset password, require change on next login
print(f"\n=== Resetting password ===")
try:
service.users().update(
userKey=user_email,
body={
'password': temp_pass,
'changePasswordAtNextLogin': True,
'suspended': False,
}
).execute()
print(f"[OK] Password reset successful")
print(f"[OK] Account unsuspended (if it was)")
print(f"[OK] Must change password on first login")
print(f"\nTemporary password: {temp_pass}")
print(f"\nGive Kyla:")
print(f" Email: {user_email}")
print(f" Password: {temp_pass}")
print(f" URL: https://accounts.google.com")
print(f" She will be prompted to change password and set up MFA")
except Exception as e:
print(f"[ERROR] Password reset failed: {e}")

View File

@@ -1,25 +0,0 @@
"""Reset password for office@lonestarelectrical.net - attempt 2, no force change"""
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = ['https://www.googleapis.com/auth/admin.directory.user']
creds = service_account.Credentials.from_service_account_file(
'temp/acg-msp-access-8f72339997e5.json', scopes=SCOPES
)
delegated = creds.with_subject('sysadmin@lonestarelectrical.net')
service = build('admin', 'directory_v1', credentials=delegated)
user_email = 'office@lonestarelectrical.net'
new_pass = 'LoneStar2026!!'
service.users().update(
userKey=user_email,
body={
'password': new_pass,
'changePasswordAtNextLogin': False,
}
).execute()
print(f"[OK] Password reset for {user_email}")
print(f"Password: {new_pass}")

View File

@@ -1,41 +0,0 @@
"""Reset password and generate backup codes for russ@lonestarelectrical.net"""
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = [
'https://www.googleapis.com/auth/admin.directory.user',
'https://www.googleapis.com/auth/admin.directory.user.security',
]
creds = service_account.Credentials.from_service_account_file(
'temp/acg-msp-access-8f72339997e5.json', scopes=SCOPES
)
delegated = creds.with_subject('sysadmin@lonestarelectrical.net')
service = build('admin', 'directory_v1', credentials=delegated)
user_email = 'russ@lonestarelectrical.net'
# Check user
print(f"=== {user_email} ===")
user = service.users().get(userKey=user_email).execute()
print(f"Name: {user.get('name', {}).get('fullName', 'N/A')}")
print(f"2SV Enrolled: {user.get('isEnrolledIn2Sv', False)}")
print(f"2SV Enforced: {user.get('isEnforcedIn2Sv', False)}")
print(f"Last Login: {user.get('lastLoginTime', 'N/A')}")
# Reset password
new_pass = 'LoneStar2026!!'
service.users().update(
userKey=user_email,
body={'password': new_pass, 'changePasswordAtNextLogin': False, 'suspended': False}
).execute()
print(f"\n[OK] Password reset: {new_pass}")
# Generate backup codes
service.verificationCodes().generate(userKey=user_email).execute()
result = service.verificationCodes().list(userKey=user_email).execute()
codes = result.get('items', [])
if codes:
print(f"\nBackup codes:")
for c in codes:
print(f" {c.get('verificationCode')}")

View File

@@ -1,404 +0,0 @@
#!/usr/bin/env python3
"""
M365 Security Scan - Check all accounts for compromise indicators
Scans: Sign-in logs, inbox rules, OAuth grants, MFA methods, forwarding
"""
import requests
import json
from datetime import datetime, timedelta
# Claude-MSP-Access Multi-Tenant App
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANTS = {
"Valley Wide Plastering": {
"tenant_id": "5c53ae9f-7071-4248-b834-8685b646450f",
"domain": "valleywideplastering.com"
},
"BG Builders LLC": {
"tenant_id": "ededa4fb-f6eb-4398-851d-5eb3e11fab27",
"domain": "bgbuildersllc.com"
}
}
# Known suspicious patterns
SUSPICIOUS_RULE_PATTERNS = [".", "..", "...", "spam", "junk", "filter"]
SUSPICIOUS_OAUTH_APPS = ["gmail", "yahoo", "p2p", "autoforward"]
US_COUNTRY_CODES = ["US", "United States"]
def get_token(tenant_id):
"""Get Graph API access token for tenant"""
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
data = {
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials"
}
resp = requests.post(url, data=data)
if resp.status_code == 200:
return resp.json().get("access_token")
else:
print(f" [ERROR] Token failed: {resp.status_code} - {resp.text[:200]}")
return None
def graph_get(token, endpoint, params=None):
"""Make Graph API GET request"""
headers = {"Authorization": f"Bearer {token}"}
url = f"https://graph.microsoft.com/v1.0{endpoint}"
resp = requests.get(url, headers=headers, params=params)
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 404:
return None
else:
return {"error": resp.status_code, "message": resp.text[:200]}
def graph_get_beta(token, endpoint, params=None):
"""Make Graph API beta GET request"""
headers = {"Authorization": f"Bearer {token}"}
url = f"https://graph.microsoft.com/beta{endpoint}"
resp = requests.get(url, headers=headers, params=params)
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 404:
return None
else:
return {"error": resp.status_code, "message": resp.text[:200]}
def check_signin_logs(token, user_id, user_email, days=30):
"""Check sign-in logs for foreign/suspicious IPs"""
issues = []
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
# Get sign-in logs
params = {
"$filter": f"userId eq '{user_id}' and createdDateTime ge {cutoff}",
"$top": 100,
"$orderby": "createdDateTime desc"
}
result = graph_get_beta(token, "/auditLogs/signIns", params)
if result and "value" in result:
foreign_logins = []
failed_foreign = []
for signin in result["value"]:
location = signin.get("location", {})
country = location.get("countryOrRegion", "Unknown")
status = signin.get("status", {})
error_code = status.get("errorCode", 0)
ip = signin.get("ipAddress", "Unknown")
if country not in US_COUNTRY_CODES and country != "Unknown":
entry = {
"ip": ip,
"country": country,
"city": location.get("city", "Unknown"),
"time": signin.get("createdDateTime"),
"success": error_code == 0,
"error": error_code
}
if error_code == 0:
foreign_logins.append(entry)
else:
failed_foreign.append(entry)
if foreign_logins:
issues.append({
"type": "FOREIGN_SUCCESS_LOGIN",
"severity": "CRITICAL",
"count": len(foreign_logins),
"details": foreign_logins[:5] # Top 5
})
if failed_foreign:
# Group by country
countries = list(set([f["country"] for f in failed_foreign]))
issues.append({
"type": "FOREIGN_FAILED_ATTEMPTS",
"severity": "INFO",
"count": len(failed_foreign),
"countries": countries
})
elif result and "error" in result:
if result["error"] != 404:
issues.append({"type": "SIGNIN_LOG_ERROR", "severity": "WARNING", "details": result})
return issues
def check_inbox_rules(token, user_id, user_email):
"""Check for malicious inbox rules"""
issues = []
result = graph_get(token, f"/users/{user_id}/mailFolders/inbox/messageRules")
if result and "value" in result:
for rule in result["value"]:
name = rule.get("displayName", "")
is_enabled = rule.get("isEnabled", False)
# Check for suspicious patterns
suspicious = False
reasons = []
# Short/dot names
if name in SUSPICIOUS_RULE_PATTERNS or len(name) <= 2:
suspicious = True
reasons.append(f"Suspicious name: '{name}'")
# Rules that delete/move and mark read
actions = rule.get("actions", {})
if actions.get("markAsRead") and (actions.get("delete") or actions.get("moveToFolder")):
suspicious = True
reasons.append("Marks read + moves/deletes")
# Stop processing
if actions.get("stopProcessingRules") and (actions.get("moveToFolder") or actions.get("delete")):
suspicious = True
reasons.append("Stops processing + hides mail")
# Forwarding rules
if actions.get("forwardTo") or actions.get("forwardAsAttachmentTo") or actions.get("redirectTo"):
forward_targets = actions.get("forwardTo", []) + actions.get("forwardAsAttachmentTo", []) + actions.get("redirectTo", [])
suspicious = True
reasons.append(f"Forwards to external: {forward_targets}")
if suspicious and is_enabled:
issues.append({
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": name,
"rule_id": rule.get("id"),
"reasons": reasons
})
elif result and "error" in result:
if result["error"] != 404:
issues.append({"type": "INBOX_RULE_ERROR", "severity": "WARNING", "details": result})
return issues
def check_oauth_grants(token, user_id, user_email):
"""Check for suspicious OAuth app grants"""
issues = []
result = graph_get(token, f"/users/{user_id}/oauth2PermissionGrants")
if result and "value" in result:
for grant in result["value"]:
client_id = grant.get("clientId", "")
scope = grant.get("scope", "")
# Get app details
app_result = graph_get(token, f"/servicePrincipals/{client_id}")
app_name = app_result.get("displayName", "Unknown") if app_result else "Unknown"
# Check for suspicious apps
suspicious = False
for pattern in SUSPICIOUS_OAUTH_APPS:
if pattern.lower() in app_name.lower():
suspicious = True
break
# Check for sensitive scopes
sensitive_scopes = ["Mail.ReadWrite", "Mail.Send", "MailboxSettings", "full_access"]
has_sensitive = any(s.lower() in scope.lower() for s in sensitive_scopes)
if suspicious or (has_sensitive and "Microsoft" not in app_name):
issues.append({
"type": "SUSPICIOUS_OAUTH_APP",
"severity": "HIGH" if suspicious else "MEDIUM",
"app_name": app_name,
"client_id": client_id,
"scope": scope
})
return issues
def check_mfa_methods(token, user_id, user_email):
"""Check MFA methods for suspicious devices"""
issues = []
result = graph_get(token, f"/users/{user_id}/authentication/methods")
if result and "value" in result:
methods = []
for method in result["value"]:
method_type = method.get("@odata.type", "")
if "phone" in method_type.lower():
phone = method.get("phoneNumber", "Unknown")
methods.append({"type": "phone", "value": phone})
elif "microsoftAuthenticator" in method_type:
device = method.get("displayName", method.get("deviceTag", "Unknown"))
methods.append({"type": "authenticator", "device": device})
elif "fido2" in method_type.lower():
methods.append({"type": "fido2", "model": method.get("model", "Unknown")})
# Flag if multiple authenticator devices (potential attacker device)
auth_devices = [m for m in methods if m.get("type") == "authenticator"]
if len(auth_devices) > 2:
issues.append({
"type": "MULTIPLE_AUTH_DEVICES",
"severity": "MEDIUM",
"count": len(auth_devices),
"devices": auth_devices
})
return issues
def check_mailbox_settings(token, user_id, user_email):
"""Check mailbox for forwarding/auto-replies"""
issues = []
result = graph_get(token, f"/users/{user_id}/mailboxSettings")
if result and "error" not in result:
# Check auto-forwarding
# Note: Graph API doesn't expose SMTP forwarding directly, need Exchange
# Check automatic replies
auto_reply = result.get("automaticRepliesSetting", {})
if auto_reply.get("status") == "alwaysEnabled":
issues.append({
"type": "AUTO_REPLY_ALWAYS_ON",
"severity": "LOW",
"message": auto_reply.get("internalReplyMessage", "")[:100]
})
return issues
def scan_tenant(tenant_name, tenant_info):
"""Scan all users in a tenant"""
print(f"\n{'='*60}")
print(f"Scanning: {tenant_name}")
print(f"Tenant ID: {tenant_info['tenant_id']}")
print(f"{'='*60}")
token = get_token(tenant_info["tenant_id"])
if not token:
return {"error": "Failed to get token - admin consent may be needed"}
# Get all users
users_result = graph_get(token, "/users", {"$select": "id,displayName,mail,userPrincipalName,accountEnabled"})
if not users_result or "value" not in users_result:
return {"error": f"Failed to get users: {users_result}"}
users = users_result["value"]
print(f"Found {len(users)} users")
results = {
"tenant": tenant_name,
"scan_time": datetime.utcnow().isoformat(),
"total_users": len(users),
"clean_users": [],
"flagged_users": [],
"disabled_users": [],
"errors": []
}
for user in users:
user_id = user.get("id")
email = user.get("mail") or user.get("userPrincipalName", "Unknown")
name = user.get("displayName", "Unknown")
enabled = user.get("accountEnabled", True)
if not enabled:
results["disabled_users"].append({"name": name, "email": email})
print(f" [SKIP] {name} - disabled")
continue
print(f" Checking: {name} ({email})...", end=" ")
all_issues = []
# Run all checks
try:
all_issues.extend(check_signin_logs(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "signin_logs", "error": str(e)})
try:
all_issues.extend(check_inbox_rules(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "inbox_rules", "error": str(e)})
try:
all_issues.extend(check_oauth_grants(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "oauth_grants", "error": str(e)})
try:
all_issues.extend(check_mfa_methods(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "mfa_methods", "error": str(e)})
try:
all_issues.extend(check_mailbox_settings(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "mailbox_settings", "error": str(e)})
# Categorize by severity
critical = [i for i in all_issues if i.get("severity") == "CRITICAL"]
high = [i for i in all_issues if i.get("severity") == "HIGH"]
if critical or high:
results["flagged_users"].append({
"name": name,
"email": email,
"user_id": user_id,
"issues": all_issues
})
print(f"[FLAGGED] {len(critical)} critical, {len(high)} high")
else:
results["clean_users"].append({"name": name, "email": email})
info_issues = [i for i in all_issues if i.get("severity") == "INFO"]
if info_issues:
print(f"[OK] ({len(info_issues)} info)")
else:
print("[OK]")
return results
def main():
print("M365 Security Scan")
print(f"Started: {datetime.utcnow().isoformat()}")
all_results = {}
for tenant_name, tenant_info in TENANTS.items():
try:
results = scan_tenant(tenant_name, tenant_info)
all_results[tenant_name] = results
except Exception as e:
all_results[tenant_name] = {"error": str(e)}
print(f" [ERROR] Tenant scan failed: {e}")
# Save results
output_file = "/Users/azcomputerguru/ClaudeTools/temp/m365_security_scan_results.json"
with open(output_file, "w") as f:
json.dump(all_results, f, indent=2)
# Print summary
print("\n" + "="*60)
print("SCAN SUMMARY")
print("="*60)
for tenant_name, results in all_results.items():
print(f"\n{tenant_name}:")
if "error" in results:
print(f" [ERROR] {results['error']}")
else:
print(f" Total users: {results['total_users']}")
print(f" Clean: {len(results['clean_users'])}")
print(f" Flagged: {len(results['flagged_users'])}")
print(f" Disabled: {len(results['disabled_users'])}")
if results["flagged_users"]:
print("\n FLAGGED ACCOUNTS:")
for user in results["flagged_users"]:
print(f" - {user['name']} ({user['email']})")
for issue in user["issues"]:
print(f" [{issue['severity']}] {issue['type']}")
print(f"\nResults saved to: {output_file}")
if __name__ == "__main__":
main()

View File

@@ -1,274 +0,0 @@
{
"Valley Wide Plastering": {
"tenant": "Valley Wide Plastering",
"scan_time": "2026-03-06T01:21:31.514321",
"total_users": 33,
"clean_users": [
{
"name": "Adolfo Suarez",
"email": "adolfos@valleywideplastering.com"
},
{
"name": "Toni",
"email": "billing@valleywideplastering.onmicrosoft.com"
},
{
"name": "Brian",
"email": "Brian@valleywideplastering.com"
},
{
"name": "Carlos Reyes",
"email": "carlos@valleywideplastering.com"
},
{
"name": "Charlie Jones",
"email": "charlie@valleywideplastering.com"
},
{
"name": "Chris Guerrero",
"email": "chris@valleywideplastering.com"
},
{
"name": "Customer Service",
"email": "customerservice@valleywideplastering.com"
},
{
"name": "Customer Service",
"email": "customerservice@valleywideplastering.onmicrosoft.com"
},
{
"name": "Bart Graffin",
"email": "estimating@valleywideplastering.com"
},
{
"name": "Fax Inbox",
"email": "faxinbox@valleywideplastering.com"
},
{
"name": "Fermin Matta",
"email": "fermin@valleywideplastering.com"
},
{
"name": "Francisco Arias",
"email": "franciscoa@valleywideplastering.com"
},
{
"name": "VWP Insurance",
"email": "insurance@valleywideplastering.com"
},
{
"name": "Issac Chavez",
"email": "isaacc@valleywideplastering.com"
},
{
"name": "JR Guerrero",
"email": "j-r@valleywideplastering.com"
},
{
"name": "Jaime Hernandez",
"email": "jaimebh@valleywideplastering.com"
},
{
"name": "Jesse Guerrero",
"email": "jesse@valleywideplastering.com"
},
{
"name": "JR Guerrero",
"email": "jr@CASARICA.NET"
},
{
"name": "Juan Leal",
"email": "juan@valleywideplastering.com"
},
{
"name": "Kayla Guerrero",
"email": "kayla@valleywideplastering.com"
},
{
"name": "Orders VWP",
"email": "orders@valleywideplastering.com"
},
{
"name": "Payroll VWP",
"email": "payroll@valleywideplastering.com"
},
{
"name": "Ron Winger",
"email": "ron@valleywideplastering.com"
},
{
"name": "Rose Guerrero",
"email": "rose@valleywideplastering.com"
},
{
"name": "Ryan Guerrero",
"email": "ryan@valleywideplastering.com"
},
{
"name": "Sammy Montijo",
"email": "sammy@valleywideplastering.com"
},
{
"name": "Shelly Dooley",
"email": "shelly@valleywideplastering.com"
},
{
"name": "Spro VWP",
"email": "spro@valleywideplastering.com"
},
{
"name": "Computer Guru",
"email": "sysadmin@valleywideplastering.com"
},
{
"name": "Teresa Carpio",
"email": "teresa@valleywideplastering.com"
},
{
"name": "Ty Fetters",
"email": "Ty@CASARICA.NET"
}
],
"flagged_users": [
{
"name": "Accounts Payable",
"email": "acctpay@valleywideplastering.com",
"user_id": "e70d7ec5-72f3-4b80-9614-e6bd5380b773",
"issues": [
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "Order Acknowledgment ",
"rule_id": "AQAAANfcAXQ=",
"reasons": [
"Stops processing + hides mail"
]
}
]
},
{
"name": "Billing Clerk",
"email": "billing@valleywideplastering.com",
"user_id": "4f708b80-e537-4f63-92d3-5feedfa28244",
"issues": [
{
"type": "FOREIGN_FAILED_ATTEMPTS",
"severity": "INFO",
"count": 15,
"countries": [
"GN",
"SG",
"ID",
"CZ",
"CN",
"BR",
"IT",
"ZA",
"VN",
"PH",
"CA",
"AR",
"AL"
]
},
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "Tim Wolf",
"rule_id": "AQAAAFDUDZY=",
"reasons": [
"Stops processing + hides mail"
]
},
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "donotreply@pulte.com",
"rule_id": "AQAAADPeesE=",
"reasons": [
"Stops processing + hides mail"
]
},
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "ssrs-donotreply@pulte.com",
"rule_id": "AQAAADJQZww=",
"reasons": [
"Stops processing + hides mail"
]
}
]
}
],
"disabled_users": [],
"errors": []
},
"BG Builders LLC": {
"tenant": "BG Builders LLC",
"scan_time": "2026-03-06T01:54:05.702139",
"total_users": 14,
"clean_users": [
{
"name": "Accounting",
"email": "Accounting@sonorangreenllc.com"
},
{
"name": "Accounts Payable",
"email": "accountspayable@sonorangreenllc.com"
},
{
"name": "admin",
"email": "admin@bgbuildersllc.com"
},
{
"name": "Balynda Western",
"email": "balynda@bgbuildersllc.com"
},
{
"name": "Barry Walling",
"email": "barry@bgbuildersllc.com"
},
{
"name": "Barry Walling",
"email": "Barry@sonorangreenllc.com"
},
{
"name": "Chad Bradford",
"email": "chad@bgbuildersllc.com"
},
{
"name": "Lesley Roth",
"email": "lesley@bgbuildersllc.com"
},
{
"name": "Projects",
"email": "projects@bgbuildersllc.com"
},
{
"name": "Raul Flores",
"email": "raul@bgbuildersllc.com"
},
{
"name": "Shelly Dooley",
"email": "Shelly@bgbuildersllc.com"
},
{
"name": "Site Operations",
"email": "siteoperations@bgbuildersllc.com"
},
{
"name": "Computer Guru",
"email": "sysadmin@bgbuildersllc.com"
}
],
"flagged_users": [],
"disabled_users": [
{
"name": "Shaun Smith",
"email": "Shaun@bgbuildersllc.com"
}
],
"errors": []
}
}

View File

@@ -1,20 +0,0 @@
#!/bin/bash
OCC="sudo -u apache php /var/www/owncloud/occ"
echo "=== ALL versions:* COMMANDS ==="
$OCC list 2>&1 | grep -E '^\s+versions:'
echo
echo "=== ALL trashbin:* COMMANDS ==="
$OCC list 2>&1 | grep -E '^\s+trashbin:'
echo
echo "=== versions:cleanup HELP ==="
$OCC versions:cleanup --help 2>&1 | head -25
echo
echo "=== versions:expire HELP ==="
$OCC versions:expire --help 2>&1 | head -25
echo
echo "=== files_versions DIR STATE BEFORE ==="
du -sh /owncloud/pavon/files_versions 2>&1
find /owncloud/pavon/files_versions -type f 2>/dev/null | wc -l
echo
echo "=== filecache rows for pavon's versions ==="
mysql owncloud --skip-column-names <<<'SELECT COUNT(*) FROM oc_filecache fc JOIN oc_storages s ON fc.storage=s.numeric_id WHERE s.id="home::pavon" AND fc.path LIKE "files_versions/%"' 2>&1

View File

@@ -1,9 +0,0 @@
#!/bin/bash
echo "=== EXISTING GROUPS ==="
sudo -u apache php /var/www/owncloud/occ group:list 2>&1
echo
echo "=== PAVON'S GROUPS ==="
sudo -u apache php /var/www/owncloud/occ user:show pavon 2>&1 | grep -iE 'group|enabled'
echo
echo "=== APP ENABLE/DISABLE PER-GROUP SUPPORT ==="
sudo -u apache php /var/www/owncloud/occ help app:enable 2>&1 | head -20

View File

@@ -1,21 +0,0 @@
#!/bin/bash
echo === LOAD ===
uptime
echo
echo === CIFS UTILS ===
rpm -q cifs-utils 2>&1
which mount.cifs 2>&1
echo
echo === EXISTING SMB MOUNTS ===
mount | grep -iE 'cifs|smb|172.16.3.21' || echo "(none)"
echo
echo === SUBDIR FILE COUNTS ===
for d in /owncloud/pavon/files/*/; do
name="${d#/owncloud/pavon/files/}"
name="${name%/}"
count=$(find "$d" -maxdepth 4 -type f 2>/dev/null | wc -l)
echo "$count files: $name"
done
echo
echo === ESTIMATED FILES OLDER THAN 365 DAYS ===
find /owncloud/pavon/files -type f -mtime +365 2>/dev/null | wc -l

View File

@@ -1,10 +0,0 @@
#!/bin/bash
echo "=== PAVON USER DETAILS ==="
sudo -u apache php /var/www/owncloud/occ user:list-groups pavon 2>&1
echo
echo "=== ALL USERS WITH GROUPS ==="
for u in $(sudo -u apache php /var/www/owncloud/occ user:list 2>&1 | awk -F': ' '{print $2}' | tr -d ' '); do
[ -z "$u" ] && continue
grps=$(sudo -u apache php /var/www/owncloud/occ user:list-groups "$u" 2>&1 | grep -E '^\s+-' | awk -F'- ' '{print $2}' | paste -sd, -)
echo "$u: ${grps:-(no groups)}"
done

View File

@@ -1,19 +0,0 @@
#!/bin/bash
echo "=== VERSIONING APP STATUS ==="
sudo -u apache php /var/www/owncloud/occ app:list 2>&1 | grep -iE 'versions|trash'
echo
echo "=== GLOBAL VERSIONS RETENTION ==="
sudo -u apache php /var/www/owncloud/occ config:system:get versions_retention_obligation 2>&1
echo
echo "=== TRASH RETENTION ==="
sudo -u apache php /var/www/owncloud/occ config:system:get trashbin_retention_obligation 2>&1
echo
echo "=== EXISTING VERSIONS DIR FOR PAVON ==="
du -sh /owncloud/pavon/files_versions 2>&1
ls /owncloud/pavon/ 2>&1
echo
echo "=== USER LIST ==="
sudo -u apache php /var/www/owncloud/occ user:list 2>&1
echo
echo "=== PER-USER VERSIONING SETTING (if any) ==="
sudo -u apache php /var/www/owncloud/occ user:setting pavon files_versions 2>&1 || true

View File

@@ -1,31 +0,0 @@
#!/bin/bash
set -e
OCC="sudo -u apache php /var/www/owncloud/occ"
echo "=== STEP 1: Create group 'versioning_users' ==="
$OCC group:add versioning_users 2>&1 || true
echo
echo "=== STEP 2: Add all non-pavon users to the group ==="
for u in Martell anaise bst jburger mara minrec rohrbach sysadmin themarcgroup; do
$OCC group:add-member versioning_users --member "$u" 2>&1 || true
done
echo
echo "=== STEP 3: Verify membership ==="
$OCC group:list-members versioning_users 2>&1
echo
echo "=== STEP 4: Disable files_versions globally ==="
$OCC app:disable files_versions 2>&1
echo
echo "=== STEP 5: Re-enable for versioning_users group only ==="
$OCC app:enable files_versions --groups versioning_users 2>&1
echo
echo "=== STEP 6: Verify app status ==="
$OCC app:list 2>&1 | grep -A 2 -i versions
echo
echo "=== STEP 7: Verify pavon excluded ==="
$OCC user:list-groups pavon 2>&1

View File

@@ -1,45 +0,0 @@
#!/bin/bash
################################################################################
# OwnCloud VM Setup - Install SMB Client
# Run from Jupiter Unraid server
################################################################################
echo "Connecting to OwnCloud VM console..."
echo "Commands to run in the VM console:"
echo ""
echo "==================================================================="
cat << 'VMCMDS'
# Login as root / Paper123!@#-unifi!
# 1. Check if SSH is running
systemctl status sshd
# 2. If not running, start it
systemctl enable sshd --now
# 3. Install SMB client packages
dnf install -y samba-client cifs-utils
# 4. Verify installation
which smbclient
rpm -qa | grep samba
# 5. Restart Apache/httpd for OwnCloud
systemctl restart httpd
# 6. Check OwnCloud service
systemctl status httpd
# 7. Exit console
exit
VMCMDS
echo "==================================================================="
echo ""
echo "Access VM console via:"
echo " Option 1: Jupiter WebGUI (http://172.16.3.20) → VMs → OwnCloud → VNC"
echo " Option 2: Run below command on Jupiter:"
echo ""
echo "ssh root@172.16.3.20 'virsh console OwnCloud'"
echo ""
echo "After installation, refresh OwnCloud admin page."
echo "SMB/CIFS should appear in External Storage dropdown."

View File

@@ -1,160 +0,0 @@
"""Onboard drelenaparra.com - verify access, assign Exchange Admin role."""
import urllib.request, urllib.parse, json, sys, base64
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANT_ID = "f06c26c7-a314-432c-a8e4-549574b6af74"
TENANT = "drelenaparra.com"
def get_token(scope):
data = urllib.parse.urlencode({
'client_id': CLAUDE_APP, 'client_secret': CLAUDE_SECRET,
'scope': scope, 'grant_type': 'client_credentials'
}).encode()
req = urllib.request.Request(
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
data=data, method='POST')
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())['access_token']
def graph_get(token, url):
req = urllib.request.Request(url, headers={'Authorization': f'Bearer {token}'})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def graph_post(token, url, body):
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method='POST',
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
# Step 1: Get token and check permissions
print(f"[STEP 1] Getting Graph token for {TENANT}...")
token = get_token("https://graph.microsoft.com/.default")
# Decode JWT to check roles
payload = token.split('.')[1]
payload += '=' * (4 - len(payload) % 4)
decoded = json.loads(base64.urlsafe_b64decode(payload))
roles = decoded.get('roles', [])
print(f"[OK] Token acquired - {len(roles)} permissions granted")
# Step 2: List users
print(f"\n[STEP 2] Listing users...")
users = graph_get(token, f"https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName,mail")
for u in users.get('value', []):
print(f" {u['displayName']} - {u['userPrincipalName']}")
# Step 3: Find Claude SP
print(f"\n[STEP 3] Finding Claude SP...")
sp_filter = urllib.parse.quote(f"appId eq '{CLAUDE_APP}'")
sp_result = graph_get(token, f"https://graph.microsoft.com/v1.0/servicePrincipals?$filter={sp_filter}&$select=id,displayName")
if sp_result.get('value'):
sp = sp_result['value'][0]
sp_id = sp['id']
print(f"[OK] SP: {sp['displayName']} (ID: {sp_id})")
else:
print("[ERROR] SP not found")
sys.exit(1)
# Step 4: Check granted permissions
print(f"\n[STEP 4] Checking granted permissions...")
try:
grants = graph_get(token, f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}/appRoleAssignments")
roles_granted = grants.get('value', [])
resources = {}
for r in roles_granted:
res = r.get('resourceDisplayName', '?')
resources[res] = resources.get(res, 0) + 1
for res, count in sorted(resources.items()):
print(f" {res}: {count} permissions")
print(f" Total: {len(roles_granted)}")
except urllib.error.HTTPError as e:
print(f" Cannot read appRoleAssignments: HTTP {e.code}")
# Step 5: Activate and assign Exchange Admin
print(f"\n[STEP 5] Exchange Administrator role...")
try:
roles_result = graph_get(token, "https://graph.microsoft.com/v1.0/directoryRoles?$select=id,displayName")
exo_role = None
for role in roles_result.get('value', []):
if role.get('displayName') == 'Exchange Administrator':
exo_role = role
break
if not exo_role:
print(" Activating from template...")
try:
exo_role = graph_post(token, "https://graph.microsoft.com/v1.0/directoryRoles",
{"roleTemplateId": "29232cdf-9323-42fd-ade2-1d097af3e4de"})
print(f" [OK] Activated")
except urllib.error.HTTPError as e:
body = e.read().decode()
if 'already' in body.lower():
# Re-fetch
roles_result = graph_get(token, "https://graph.microsoft.com/v1.0/directoryRoles?$select=id,displayName")
for role in roles_result.get('value', []):
if role.get('displayName') == 'Exchange Administrator':
exo_role = role
break
else:
print(f" [ERROR] {e.code}: {body[:200]}")
if exo_role:
exo_role_id = exo_role['id']
print(f" Role ID: {exo_role_id}")
# Assign
try:
graph_post(token, f"https://graph.microsoft.com/v1.0/directoryRoles/{exo_role_id}/members/$ref",
{"@odata.id": f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}"})
print(f" [OK] Exchange Administrator assigned to Claude SP")
except urllib.error.HTTPError as e:
body = e.read().decode()
if 'already exist' in body.lower():
print(f" [OK] Already assigned")
else:
print(f" [WARNING] {e.code}: {body[:200]}")
except urllib.error.HTTPError as e:
print(f" [ERROR] Cannot manage roles: HTTP {e.code}")
# Step 6: Test access
print(f"\n[STEP 6] Testing API access...")
tests = [
("Users", f"https://graph.microsoft.com/v1.0/users?$top=1&$select=displayName"),
("Security", "https://graph.microsoft.com/v1.0/security/alerts?$top=1"),
("AuditLogs", "https://graph.microsoft.com/v1.0/auditLogs/signIns?$top=1"),
("ConditionalAccess", "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies"),
("Devices", "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$top=1"),
]
for name, url in tests:
try:
graph_get(token, url)
print(f" [OK] {name}")
except urllib.error.HTTPError as e:
print(f" [FAIL] {name}: HTTP {e.code}")
# Step 7: Test Exchange
print(f"\n[STEP 7] Testing Exchange Online...")
try:
exo_token = get_token("https://outlook.office365.com/.default")
invoke_url = f"https://outlook.office365.com/adminapi/beta/{TENANT_ID}/InvokeCommand"
cmd = json.dumps({"CmdletInput": {"CmdletName": "Get-Mailbox", "Parameters": {"ResultSize": "1"}}}).encode()
req = urllib.request.Request(invoke_url, data=cmd, method='POST',
headers={'Authorization': f'Bearer {exo_token}', 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
if result.get('value'):
print(f" [OK] Exchange Online - {result['value'][0].get('DisplayName','?')}")
else:
print(f" [OK] Exchange responded (no mailboxes yet)")
except urllib.error.HTTPError as e:
print(f" [FAIL] Exchange: HTTP {e.code}")
except Exception as e:
print(f" [FAIL] Exchange: {e}")
print(f"\n{'='*50}")
print(f" ONBOARDING COMPLETE: {TENANT}")
print(f" Tenant ID: {TENANT_ID}")
print(f"{'='*50}")

View File

@@ -1,68 +0,0 @@
import json
d = json.load(open('D:/ClaudeTools/temp/vwp_signins_raw.json'))
signins = d.get('value', [])
print(f'Total sign-ins returned: {len(signins)}')
print()
ips = {}
locations = {}
failed = 0
risky = 0
legacy = []
for s in signins:
ts = s.get('createdDateTime', 'N/A')
ip = s.get('ipAddress', 'N/A')
loc = s.get('location', {})
city = loc.get('city', '?')
state = loc.get('state', '?')
country = loc.get('countryOrRegion', '?')
loc_str = f'{city}, {state}, {country}'
status_code = s.get('status', {}).get('errorCode', 0)
status_reason = s.get('status', {}).get('failureReason', '')
risk = s.get('riskLevelDuringSignIn', 'none')
risk_state = s.get('riskState', 'none')
app = s.get('clientAppUsed', 'N/A')
app_name = s.get('appDisplayName', 'N/A')
resource = s.get('resourceDisplayName', '')
ips[ip] = ips.get(ip, 0) + 1
locations[loc_str] = locations.get(loc_str, 0) + 1
flags = []
if status_code != 0:
failed += 1
flags.append(f'FAILED({status_code})')
if risk not in ('none', 'low', None, ''):
risky += 1
flags.append(f'RISK:{risk}')
if country not in ('US', 'Unknown', '', None):
flags.append(f'FOREIGN:{country}')
if app in ('IMAP4', 'POP3', 'SMTP', 'Authenticated SMTP', 'Other clients', 'Exchange ActiveSync'):
legacy.append({'ts': ts, 'protocol': app, 'ip': ip})
flags.append('LEGACY_AUTH')
marker = '[SUSPICIOUS]' if flags else ' '
flag_str = ' [' + '|'.join(flags) + ']' if flags else ''
print(f'{marker} {ts} | IP: {ip} | {loc_str} | App: {app_name} | Client: {app} | Resource: {resource}{flag_str}')
if status_code != 0:
print(f' Failure: {status_reason}')
print(f'\n--- Sign-in Summary ---')
print(f'Total: {len(signins)}')
print(f'Failed: {failed}')
print(f'Risky: {risky}')
print(f'Legacy auth: {len(legacy)}')
print(f'Unique IPs: {len(ips)}')
print(f'\n--- IP Breakdown ---')
for ip, cnt in sorted(ips.items(), key=lambda x: x[1], reverse=True):
print(f' {ip}: {cnt}')
print(f'\n--- Location Breakdown ---')
for loc_s, cnt in sorted(locations.items(), key=lambda x: x[1], reverse=True):
print(f' {loc_s}: {cnt}')
if legacy:
print(f'\n--- Legacy Auth Details ---')
for l in legacy:
print(f' {l["ts"]} | {l["protocol"]} | {l["ip"]}')

View File

@@ -1,291 +0,0 @@
#!/bin/bash
################################################################################
# Pavon Archive Cleanup Script
# Purpose: Delete camera footage older than 3 years (before April 2023)
# Server: 172.16.1.33 (Pavon Unraid)
# Expected Recovery: 25.2TB
# Date: April 12, 2026
################################################################################
# Configuration
BASE_PATH="/mnt/user/Storage"
LOG_DIR="/root/cleanup_logs"
LOG_FILE="${LOG_DIR}/cleanup_$(date +%Y%m%d_%H%M%S).log"
DRY_RUN=1 # 1 = dry-run (preview only), 0 = actual deletion
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Periods to delete (before April 2023)
PERIODS=(
"202212:Dec 2022"
"202301:Jan 2023"
"202302:Feb 2023"
"202303:Mar 2023"
)
################################################################################
# Functions
################################################################################
log() {
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_warn() {
echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] WARNING:${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_error() {
echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] ERROR:${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_info() {
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')] INFO:${NC} $1" | tee -a "$LOG_FILE" >&2
}
# Initialize logging
init_logging() {
mkdir -p "$LOG_DIR"
log "==================================================================="
log "Pavon Archive Cleanup Script - Started"
log "==================================================================="
log "Base Path: $BASE_PATH"
log "Mode: $([ $DRY_RUN -eq 1 ] && echo 'DRY-RUN (preview only)' || echo 'LIVE DELETION')"
log "Log File: $LOG_FILE"
log "-------------------------------------------------------------------"
}
# Calculate total size for a period
calculate_period_size() {
local pattern=$1
local description=$2
log_info "Scanning: $description (pattern: Event${pattern}*.avi)"
local file_count=0
local total_size=0
# Count files and calculate size
while IFS= read -r file; do
((file_count++))
done < <(find "$BASE_PATH"/cam* -type f -name "Event${pattern}*.avi" 2>/dev/null)
if [ $file_count -gt 0 ]; then
total_size=$(find "$BASE_PATH"/cam* -type f -name "Event${pattern}*.avi" -exec du -ch {} + 2>/dev/null | tail -1 | cut -f1)
else
total_size="0"
fi
log_info " Files: $file_count"
log_info " Size: $total_size"
echo "$file_count:$total_size"
}
# Preview what will be deleted
preview_deletion() {
log "==================================================================="
log "DELETION PREVIEW"
log "==================================================================="
local grand_total_files=0
local grand_total_size_kb=0
for period in "${PERIODS[@]}"; do
IFS=':' read -r pattern description <<< "$period"
log ""
log "--- $description ---"
result=$(calculate_period_size "$pattern" "$description")
IFS=':' read -r file_count total_size <<< "$result"
grand_total_files=$((grand_total_files + file_count))
# Show sample files
log_info "Sample files to be deleted:"
find "$BASE_PATH"/cam* -type f -name "Event${pattern}*.avi" 2>/dev/null | head -5 | while read -r file; do
size=$(du -h "$file" 2>/dev/null | cut -f1)
log_info " $size - $(basename "$file")"
done
done
log ""
log "==================================================================="
log "SUMMARY"
log "==================================================================="
log "Total files to delete: $grand_total_files"
log "Expected space recovery: 25.2TB (calculated from audit)"
log "==================================================================="
}
# Delete files for a specific period
delete_period() {
local pattern=$1
local description=$2
log ""
log "==================================================================="
log "Processing: $description"
log "==================================================================="
local deleted_count=0
local failed_count=0
local deleted_size=0
# Find and delete files
while IFS= read -r file; do
if [ $DRY_RUN -eq 1 ]; then
log_info "[DRY-RUN] Would delete: $file"
((deleted_count++))
else
size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
if rm -f "$file" 2>/dev/null; then
log_info "Deleted: $file"
((deleted_count++))
deleted_size=$((deleted_size + size))
# Progress indicator every 1000 files
if [ $((deleted_count % 1000)) -eq 0 ]; then
log "Progress: $deleted_count files deleted..."
fi
else
log_error "Failed to delete: $file"
((failed_count++))
fi
fi
done < <(find "$BASE_PATH"/cam* -type f -name "Event${pattern}*.avi" 2>/dev/null)
log "-------------------------------------------------------------------"
log "$description Complete:"
log " Deleted: $deleted_count files"
if [ $DRY_RUN -eq 0 ]; then
log " Failed: $failed_count files"
log " Space freed: $(numfmt --to=iec-i --suffix=B $deleted_size 2>/dev/null || echo 'N/A')"
fi
log "-------------------------------------------------------------------"
echo "$deleted_count:$failed_count:$deleted_size"
}
# Clean up empty directories
cleanup_empty_dirs() {
if [ $DRY_RUN -eq 1 ]; then
log_info "[DRY-RUN] Would clean up empty directories"
return
fi
log ""
log "==================================================================="
log "Cleaning up empty directories"
log "==================================================================="
local removed_dirs=0
for cam_dir in "$BASE_PATH"/cam*; do
if [ -d "$cam_dir" ]; then
# Remove empty date directories
find "$cam_dir" -type d -empty -delete 2>/dev/null
((removed_dirs++))
fi
done
log "Empty directories cleaned: $removed_dirs camera folders checked"
}
# Main execution
main() {
init_logging
# Verify base path exists
if [ ! -d "$BASE_PATH" ]; then
log_error "Base path does not exist: $BASE_PATH"
exit 1
fi
# Show preview
preview_deletion
# If dry-run, stop here
if [ $DRY_RUN -eq 1 ]; then
log ""
log "==================================================================="
log "DRY-RUN COMPLETE - No files were deleted"
log "==================================================================="
log "To execute actual deletion, edit this script and set: DRY_RUN=0"
log "Or run with: DRY_RUN=0 $0"
log "==================================================================="
exit 0
fi
# Confirm before deletion
log ""
log_warn "==================================================================="
log_warn "FINAL CONFIRMATION REQUIRED"
log_warn "==================================================================="
log_warn "This will PERMANENTLY delete approximately 25.2TB of data"
log_warn "Files from: Dec 2022 - Mar 2023"
log_warn ""
read -p "Type 'DELETE' to confirm: " confirmation
if [ "$confirmation" != "DELETE" ]; then
log_error "Deletion cancelled by user"
exit 1
fi
# Execute deletion
log ""
log "==================================================================="
log "STARTING DELETION PROCESS"
log "==================================================================="
local grand_total_deleted=0
local grand_total_failed=0
local grand_total_size=0
for period in "${PERIODS[@]}"; do
IFS=':' read -r pattern description <<< "$period"
result=$(delete_period "$pattern" "$description")
IFS=':' read -r deleted failed size <<< "$result"
grand_total_deleted=$((grand_total_deleted + deleted))
grand_total_failed=$((grand_total_failed + failed))
grand_total_size=$((grand_total_size + size))
done
# Clean up empty directories
cleanup_empty_dirs
# Final summary
log ""
log "==================================================================="
log "DELETION COMPLETE"
log "==================================================================="
log "Total files deleted: $grand_total_deleted"
log "Total files failed: $grand_total_failed"
log "Total space freed: $(numfmt --to=iec-i --suffix=B $grand_total_size 2>/dev/null || echo 'N/A')"
log "Log file: $LOG_FILE"
log "==================================================================="
# Show new disk usage
log ""
log "Updated disk usage:"
df -h "$BASE_PATH" | tee -a "$LOG_FILE"
}
################################################################################
# Script execution
################################################################################
# Allow override via environment variable
if [ -n "$DRY_RUN" ]; then
DRY_RUN=$DRY_RUN
fi
main "$@"

View File

@@ -1,38 +0,0 @@
# Get CIPP auth token
$body = @{
client_id = '420cb849-542d-4374-9cb2-3d8ae0e1835b'
client_secret = 'MOn8Q~otmxJPLvmL~_aCVTV8Va4t4~SrYrukGbJT'
scope = 'api://420cb849-542d-4374-9cb2-3d8ae0e1835b/.default'
grant_type = 'client_credentials'
}
$token = (Invoke-RestMethod -Uri 'https://login.microsoftonline.com/ce61461e-81a0-4c84-bb4a-7b354a9a356d/oauth2/v2.0/token' -Method POST -Body $body).access_token
Write-Host "Token obtained: $($token.Substring(0,20))..."
$headers = @{ Authorization = "Bearer $token" }
$baseUrl = 'https://cippcanvb.azurewebsites.net/api'
# Test auth - list tenants
try {
$tenants = Invoke-RestMethod -Uri "$baseUrl/ListTenants" -Headers $headers
Write-Host "Auth works. Tenants found: $($tenants.Count)"
} catch {
Write-Host "ListTenants failed: $($_.Exception.Message)"
}
# Try ExecResetPass with query string approach (some CIPP endpoints use GET params)
try {
$uri = "$baseUrl/ExecResetPass?TenantFilter=sonorangreenllc.com&ID=lesley@bgbuildersllc.com&password=Builder2026!&MustChange=false"
$result = Invoke-RestMethod -Uri $uri -Headers $headers
Write-Host "Result: $($result | ConvertTo-Json -Depth 5)"
} catch {
Write-Host "GET approach failed: $($_.Exception.Message)"
# Try as POST with different body format
try {
$resetBody = '{"TenantFilter":"sonorangreenllc.com","ID":"lesley@bgbuildersllc.com","password":"Builder2026!","MustChange":false}'
$result = Invoke-RestMethod -Uri "$baseUrl/ExecResetPass" -Method POST -Headers $headers -Body $resetBody -ContentType 'application/json'
Write-Host "POST Result: $($result | ConvertTo-Json -Depth 5)"
} catch {
Write-Host "POST also failed: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)"
}
}

View File

@@ -1,18 +0,0 @@
#!/bin/bash
# Scan for MAC address ending in B8:56
echo "[INFO] Scanning local network for MAC ending in B8:56..."
echo "[INFO] Current subnet: 192.168.0.0/24"
echo ""
# Get ARP cache
arp -a | grep -i "b8:56" > /tmp/mac_result.txt
if [ -s /tmp/mac_result.txt ]; then
echo "[SUCCESS] Found device(s):"
cat /tmp/mac_result.txt
else
echo "[INFO] No device with MAC ending in B8:56 found in current ARP cache"
echo "[INFO] Showing all ARP entries:"
arp -a
fi

View File

@@ -1,78 +0,0 @@
#!/bin/bash
# Smart Slider 3 Pro Security Scanner for IX Server
# Scans all WordPress installations for Smart Slider plugin
echo "[INFO] IX Server Smart Slider 3 Security Scan"
echo "[INFO] Date: $(date)"
echo "=============================================="
echo ""
# Initialize counters
total_wp=0
found_free=0
found_pro=0
# Create temporary file for results
results_file="/tmp/smart_slider_scan_$(date +%s).txt"
echo "[INFO] Scanning for WordPress installations..."
echo ""
# Find all WordPress installations
for wpconfig in $(find /home/*/public_html -maxdepth 3 -name "wp-config.php" -type f 2>/dev/null); do
((total_wp++))
wpdir=$(dirname "$wpconfig")
plugindir="$wpdir/wp-content/plugins"
site_user=$(echo "$wpdir" | cut -d'/' -f3)
# Check for Smart Slider 3 PRO
if [ -d "$plugindir/nextend-smart-slider3-pro" ]; then
((found_pro++))
version=$(grep -o "Version: .*" "$plugindir/nextend-smart-slider3-pro/nextend-smart-slider3-pro.php" 2>/dev/null | head -1 | cut -d' ' -f2)
echo "[WARNING] SMART SLIDER 3 PRO FOUND" | tee -a "$results_file"
echo " User: $site_user" | tee -a "$results_file"
echo " Path: $wpdir" | tee -a "$results_file"
echo " Version: ${version:-Unknown}" | tee -a "$results_file"
# Check if it's active
if grep -q "nextend-smart-slider3-pro" "$wpdir/wp-content/plugins" 2>/dev/null; then
echo " Status: Likely Active" | tee -a "$results_file"
fi
echo "" | tee -a "$results_file"
# Check for Smart Slider 3 FREE
elif [ -d "$plugindir/smart-slider-3" ]; then
((found_free++))
version=$(grep -o "Version: .*" "$plugindir/smart-slider-3/smart-slider-3.php" 2>/dev/null | head -1 | cut -d' ' -f2)
echo "[INFO] Smart Slider 3 (Free) Found" | tee -a "$results_file"
echo " User: $site_user" | tee -a "$results_file"
echo " Path: $wpdir" | tee -a "$results_file"
echo " Version: ${version:-Unknown}" | tee -a "$results_file"
echo "" | tee -a "$results_file"
fi
done
echo "=============================================="
echo "[OK] Scan Complete"
echo ""
echo "SUMMARY:"
echo " Total WordPress sites: $total_wp"
echo " Smart Slider 3 Pro: $found_pro"
echo " Smart Slider 3 Free: $found_free"
echo ""
if [ $found_pro -gt 0 ]; then
echo "[WARNING] SECURITY ALERT:"
echo " Smart Slider 3 Pro was compromised April 7-9, 2026"
echo " Sites with this plugin may have been infected"
echo " IMMEDIATE ACTION REQUIRED:"
echo " 1. Update Smart Slider 3 Pro to latest version"
echo " 2. Check for unauthorized users/backdoors"
echo " 3. Review recent file modifications"
echo " 4. Scan for malware"
fi
echo ""
echo "Results saved to: $results_file"

View File

@@ -1,73 +0,0 @@
#!/bin/bash
# Bootstrap script to configure sudo for ClaudeTools operations (FIXED)
# Run this ONCE with: bash temp/setup-sudo-for-claudetools-fixed.sh
set -e
echo "[INFO] Setting up passwordless sudo for ClaudeTools operations..."
# Create sudoers rule for ClaudeTools/GuruRMM operations
# NOTE: Sudoers doesn't handle paths with spaces well, so we use wildcards
cat > /tmp/claudetools-sudoers << 'EOF'
# ClaudeTools passwordless sudo rules
# Allows specific operations without password prompt
# GuruRMM agent installation and management
azcomputerguru ALL=(ALL) NOPASSWD: /bin/mkdir -p /Library/Application*
azcomputerguru ALL=(ALL) NOPASSWD: /bin/mkdir -p /Library/Logs/GuruRMM
azcomputerguru ALL=(ALL) NOPASSWD: /bin/cp /Users/azcomputerguru/ClaudeTools/projects/msp-tools/guru-rmm/agent/target/release/gururmm-agent /usr/local/bin/gururmm-agent
azcomputerguru ALL=(ALL) NOPASSWD: /bin/cp /Users/azcomputerguru/ClaudeTools/projects/msp-tools/guru-rmm/agent/agent.toml /Library/Application*/GuruRMM/agent.toml
azcomputerguru ALL=(ALL) NOPASSWD: /bin/chmod +x /usr/local/bin/gururmm-agent
azcomputerguru ALL=(ALL) NOPASSWD: /bin/chmod * /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /usr/sbin/chown * /usr/local/bin/gururmm-agent
azcomputerguru ALL=(ALL) NOPASSWD: /usr/sbin/chown * /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /usr/sbin/chown * /Library/Application*/GuruRMM
azcomputerguru ALL=(ALL) NOPASSWD: /usr/bin/tee /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl load /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl unload /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl start com.azcomputerguru.gururmm
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl stop com.azcomputerguru.gururmm
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl list
# General file operations for ClaudeTools
azcomputerguru ALL=(ALL) NOPASSWD: /bin/cat /Library/Logs/GuruRMM/*
azcomputerguru ALL=(ALL) NOPASSWD: /usr/bin/tail /Library/Logs/GuruRMM/*
EOF
# Install sudoers rule
sudo install -m 0440 /tmp/claudetools-sudoers /etc/sudoers.d/claudetools
echo "[OK] Passwordless sudo rules installed to /etc/sudoers.d/claudetools"
# Validate sudoers syntax
if sudo visudo -c -f /etc/sudoers.d/claudetools; then
echo "[OK] Sudoers syntax validated"
else
echo "[ERROR] Sudoers syntax validation failed!"
sudo rm /etc/sudoers.d/claudetools
echo "[OK] Removed broken sudoers file"
exit 1
fi
# Enable Touch ID for sudo (fallback for other operations)
if ! grep -q "pam_tid.so" /etc/pam.d/sudo 2>/dev/null; then
echo "[INFO] Enabling Touch ID for sudo..."
sudo sed -i '' '2i\
auth sufficient pam_tid.so
' /etc/pam.d/sudo
echo "[OK] Touch ID enabled for sudo"
else
echo "[OK] Touch ID already enabled for sudo"
fi
# Clean up
rm -f /tmp/claudetools-sudoers
echo ""
echo "[SUCCESS] Sudo configuration complete!"
echo ""
echo "What was configured:"
echo " - Passwordless sudo for GuruRMM agent installation/management"
echo " - Passwordless sudo for reading GuruRMM logs"
echo " - Touch ID authentication for other sudo operations"
echo ""
echo "ClaudeTools can now install the GuruRMM agent without password prompts."

View File

@@ -1,66 +0,0 @@
#!/bin/bash
# Bootstrap script to configure sudo for ClaudeTools operations
# Run this ONCE with: bash temp/setup-sudo-for-claudetools.sh
set -e
echo "[INFO] Setting up passwordless sudo for ClaudeTools operations..."
# Create sudoers rule for ClaudeTools/GuruRMM operations
cat > /tmp/claudetools-sudoers << 'EOF'
# ClaudeTools passwordless sudo rules
# Allows specific operations without password prompt
# GuruRMM agent installation and management
azcomputerguru ALL=(ALL) NOPASSWD: /bin/mkdir -p /Library/Application Support/GuruRMM
azcomputerguru ALL=(ALL) NOPASSWD: /bin/mkdir -p /Library/Logs/GuruRMM
azcomputerguru ALL=(ALL) NOPASSWD: /bin/cp /Users/azcomputerguru/ClaudeTools/projects/msp-tools/guru-rmm/agent/target/release/gururmm-agent /usr/local/bin/gururmm-agent
azcomputerguru ALL=(ALL) NOPASSWD: /bin/cp /Users/azcomputerguru/ClaudeTools/projects/msp-tools/guru-rmm/agent/agent.toml /Library/Application Support/GuruRMM/agent.toml
azcomputerguru ALL=(ALL) NOPASSWD: /bin/chmod +x /usr/local/bin/gururmm-agent
azcomputerguru ALL=(ALL) NOPASSWD: /bin/chmod 644 /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /usr/sbin/chown root:wheel /usr/local/bin/gururmm-agent
azcomputerguru ALL=(ALL) NOPASSWD: /usr/sbin/chown root:wheel /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /usr/sbin/chown -R root:wheel /Library/Application Support/GuruRMM
azcomputerguru ALL=(ALL) NOPASSWD: /usr/bin/tee /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl load /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl unload /Library/LaunchDaemons/com.azcomputerguru.gururmm.plist
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl start com.azcomputerguru.gururmm
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl stop com.azcomputerguru.gururmm
azcomputerguru ALL=(ALL) NOPASSWD: /bin/launchctl list
# General file operations for ClaudeTools
azcomputerguru ALL=(ALL) NOPASSWD: /bin/cat /Library/Logs/GuruRMM/*
azcomputerguru ALL=(ALL) NOPASSWD: /usr/bin/tail -f /Library/Logs/GuruRMM/*
EOF
# Install sudoers rule
sudo install -m 0440 /tmp/claudetools-sudoers /etc/sudoers.d/claudetools
echo "[OK] Passwordless sudo rules installed to /etc/sudoers.d/claudetools"
# Validate sudoers syntax
sudo visudo -c -f /etc/sudoers.d/claudetools
echo "[OK] Sudoers syntax validated"
# Enable Touch ID for sudo (fallback for other operations)
if ! grep -q "pam_tid.so" /etc/pam.d/sudo 2>/dev/null; then
echo "[INFO] Enabling Touch ID for sudo..."
sudo sed -i '' '2i\
auth sufficient pam_tid.so
' /etc/pam.d/sudo
echo "[OK] Touch ID enabled for sudo"
else
echo "[OK] Touch ID already enabled for sudo"
fi
# Clean up
rm -f /tmp/claudetools-sudoers
echo ""
echo "[SUCCESS] Sudo configuration complete!"
echo ""
echo "What was configured:"
echo " - Passwordless sudo for GuruRMM agent installation/management"
echo " - Passwordless sudo for reading GuruRMM logs"
echo " - Touch ID authentication for other sudo operations"
echo ""
echo "ClaudeTools can now install the GuruRMM agent without password prompts."

View File

@@ -1,97 +0,0 @@
Total sign-ins returned: 50
2026-03-05T10:52:00Z | IP: 23.234.101.73 | Brooklyn, New York, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: Office365 Shell WCSS-Server
2026-03-05T10:52:00Z | IP: 23.234.101.73 | Brooklyn, New York, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: Microsoft Graph
2026-03-05T10:52:00Z | IP: 23.234.101.73 | Brooklyn, New York, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: Microsoft Graph
2026-03-05T10:52:00Z | IP: 23.234.101.73 | Brooklyn, New York, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: IrisSelectionFrontDoor
2026-03-05T10:41:44Z | IP: 23.234.101.73 | Brooklyn, New York, US | App: Microsoft Account Controls V2 | Client: Browser | Resource: Microsoft Graph
2026-03-04T23:02:25Z | IP: 4.18.160.106 | Leesburg, Florida, US | App: One Outlook Web | Client: Browser | Resource: OfficeServicesManager
2026-03-04T23:00:16Z | IP: 4.18.160.106 | Leesburg, Florida, US | App: One Outlook Web | Client: Browser | Resource: Microsoft 365 App Catalog Services
2026-03-04T23:00:16Z | IP: 4.18.160.106 | Leesburg, Florida, US | App: One Outlook Web | Client: Browser | Resource: Microsoft 365 App Catalog Services
2026-03-04T22:59:47Z | IP: 4.18.160.106 | Leesburg, Florida, US | App: One Outlook Web | Client: Browser | Resource: Office 365 Exchange Online
2026-03-04T22:59:44Z | IP: 4.18.160.106 | Leesburg, Florida, US | App: One Outlook Web | Client: Browser | Resource: OfficeServicesManager
2026-03-04T22:59:44Z | IP: 4.18.160.106 | Leesburg, Florida, US | App: One Outlook Web | Client: Browser | Resource: Office 365 Exchange Online
2026-03-04T20:43:46Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Microsoft Account Controls V2 | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:43:45Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Microsoft Account Controls V2 | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:43:44Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Microsoft Account Controls V2 | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:43:00Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Microsoft Account Controls V2 | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:42:45Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: My Profile | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:38:24Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:38:24Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: Office365 Shell WCSS-Server
2026-03-04T20:38:24Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:38:24Z | IP: 23.234.100.73 | Chicago, Illinois, US | App: Office365 Shell WCSS-Client | Client: Browser | Resource: IrisSelectionFrontDoor
2026-03-04T20:34:36Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: Microsoft 365 Admin portal | Client: Browser | Resource: Microsoft Graph
2026-03-04T20:34:23Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: Microsoft Office 365 Portal | Client: Browser | Resource: Windows Azure Active Directory
2026-03-04T20:22:22Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: One Outlook Web | Client: Browser | Resource: Office 365 Exchange Online
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:22:00Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:21:59Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:21:59Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:21:59Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:21:59Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:21:59Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
[SUSPICIOUS] 2026-03-04T20:21:59Z | IP: 23.234.100.200 | Chicago, Illinois, US | App: ppuxdevcenter | Client: Unknown | Resource: Windows Azure Active Directory [FAILED(53003)]
Failure: Access has been blocked by Conditional Access policies. The access policy does not allow token issuance.
--- Sign-in Summary ---
Total: 50
Failed: 27
Risky: 0
Legacy auth: 0
Unique IPs: 4
--- IP Breakdown ---
23.234.100.200: 30
23.234.100.73: 9
4.18.160.106: 6
23.234.101.73: 5
--- Location Breakdown ---
Chicago, Illinois, US: 39
Leesburg, Florida, US: 6
Brooklyn, New York, US: 5

View File

@@ -1,140 +0,0 @@
# T7 Home Directory Migration - Analysis & Plan
# Generated: 2026-03-02 by remote Claude session
# Context: David's MacBook Air, Samsung T7 external drive
## Problem Summary
The previous attempt to move David's entire home directory to /Volumes/T7/Users/David
failed because macOS does NOT mount external USB drives before login. The disk
arbitration daemon (diskarbitrationd) runs in the user session context, which only
starts after authentication. Setting NFSHomeDirectory to /Volumes/T7/Users/David
will NEVER work reliably.
The plist was reverted to /Users/David to restore login. Do NOT change it back.
## Current Disk State (2026-03-02)
Data Volume (/System/Volumes/Data): 189GB used / 228GB total (94% full, 13GB free)
### Internal /Users/David - 27GB total
- Music: 20GB
- Pictures: 1.8GB
- Library: 1.8GB
- Dropbox: 1.3GB
- Evernote: 1.2GB
- Dropbox (Old): 656MB
- Applications: 211MB
- .local: 183MB
- iCloudDrive: 32MB
- Desktop: unknown (TCC blocked from SSH)
- Documents: unknown (TCC blocked from SSH, 307 items)
- Downloads: unknown (TCC blocked from SSH, 169 items)
- Movies: 136K
- 3D Objects: 3.6MB
### Internal /Users/Shared - 46GB total
- Shared Files From PC: 20GB
- Session Guitarist Strummed Acoustic: 7.7GB
- Session Guitarist Picked Acoustic: 7.7GB
- Session Bassist Upright Bass: 6.2GB
- Ample Sound + NI Resources: ~900MB
### T7 (/Volumes/T7) - 472GB used / 931GB total (459GB free)
- /Volumes/T7/Users/David already has 182GB of data from previous migration attempt
### New Volume - 348GB used / 1.8TB total (1.5TB free)
## Solution: Verify T7 copies, delete internal copies, create symlinks
The previous migration already copied data to T7. We just need to:
1. Verify T7 has the data
2. Remove the internal copy
3. Create symlinks from internal -> T7
All commands MUST run from David's terminal (TCC blocks T7 access via SSH).
## What STAYS on internal (required for login/system)
- Library/ - keychain, preferences, app configs needed at login
- .CFUserTextEncoding
- .DS_Store
- .zshrc, .zsh_history, .zsh_sessions
- .claude/, .claude.json, claude-sessions/ - active Claude tooling
- .cache - 8K, trivial
- .sentry - 20K, trivial
- .Trash/ - managed by system
- nohup.out, testfolder, Sites - negligible
## What MOVES to T7 (symlink back) - everything non-essential
These folders should all be verified on T7, deleted from internal, and symlinked:
- Music 20GB
- Pictures 1.8GB
- Desktop unknown size, 16 items
- Documents unknown size, 307 items
- Downloads unknown size, 169 items
- Movies 136K
- Dropbox 1.3GB
- Dropbox (Old) 656MB
- Evernote 1.2GB
- Applications 211MB
- iCloudDrive 32MB
- 3D Objects 3.6MB
- .local 183MB
- BLUE SKIES PT1 .pdf 40K
- License.pdf 40K
## Procedure for each folder
For each folder listed above:
1. Verify it exists on T7:
ls -la "/Volumes/T7/Users/David/FOLDERNAME"
2. Compare file counts (quick integrity check):
find "/Users/David/FOLDERNAME" -type f | wc -l
find "/Volumes/T7/Users/David/FOLDERNAME" -type f | wc -l
3. If counts match (or T7 has more), remove internal and symlink:
rm -rf "/Users/David/FOLDERNAME"
ln -s "/Volumes/T7/Users/David/FOLDERNAME" "/Users/David/FOLDERNAME"
4. If folder is NOT on T7, move it there first:
mv "/Users/David/FOLDERNAME" "/Volumes/T7/Users/David/FOLDERNAME"
ln -s "/Volumes/T7/Users/David/FOLDERNAME" "/Users/David/FOLDERNAME"
5. Verify symlink:
ls -la "/Users/David/FOLDERNAME"
# Should show: FOLDERNAME -> /Volumes/T7/Users/David/FOLDERNAME
## Phase 2 - Move Shared music libraries (saves ~46GB)
These are in /Users/Shared, not David's profile:
sudo mv "/Users/Shared/Shared Files From PC.localized" /Volumes/T7/
sudo mv "/Users/Shared/Session Guitarist - Strummed Acoustic Library" /Volumes/T7/
sudo mv "/Users/Shared/Session Guitarist - Picked Acoustic Library" /Volumes/T7/
sudo mv "/Users/Shared/Session Bassist - Upright Bass Library" /Volumes/T7/
Ask David if music apps need symlinks back or can be reconfigured to T7 paths.
## Expected Result
- Internal Data volume: freed ~70GB+
- 13GB free -> 80GB+ free
- Login works normally
- All data accessible seconds after login when T7 auto-mounts
## Important Notes
1. DO NOT change NFSHomeDirectory in the plist. Keep it as /Users/David.
2. DO NOT use fstab for pre-login USB mount. It will not work.
3. TCC blocks T7 access via SSH. ALL T7 operations from David's terminal only.
4. T7 already has 182GB from previous migration. Verify before deleting internal copies.
5. Symlinks will be "broken" at login screen - this is fine. They resolve once T7 mounts.
## SSH Access (for non-T7 operations)
- User: guru @ 192.168.4.132
- Auth: SSH key (no password needed)
- sudo: NOPASSWD configured
- Can do everything EXCEPT access /Volumes/T7 or TCC-protected folders (Desktop/Documents/Downloads)

View File

@@ -1,19 +0,0 @@
$SshExe = 'C:\Windows\System32\OpenSSH\ssh.exe'
$SshTarget = 'INTRANET\sysadmin@192.168.0.6'
# Create a test script on AD2
$testScript = @'
try {
$r = Invoke-WebRequest -Uri "http://localhost:3000/api/stats" -UseBasicParsing -TimeoutSec 5
Write-Output "STATUS: $($r.StatusCode)"
Write-Output "CONTENT: $($r.Content.Substring(0, [Math]::Min(200, $r.Content.Length)))"
} catch {
Write-Output "ERROR: $($_.Exception.Message)"
}
'@
# Write test script to AD2
$testScript | & $SshExe $SshTarget 'powershell -Command "Set-Content -Path C:\Shares\testdatadb\test-web.ps1 -Value (Get-Content -Raw -Path -)"'
# Actually, simpler - just run inline
& $SshExe $SshTarget 'powershell -NoProfile -ExecutionPolicy Bypass -Command "try { $r = Invoke-WebRequest -Uri http://localhost:3000/ -UseBasicParsing -TimeoutSec 5; Write-Output STATUS:$($r.StatusCode) } catch { Write-Output ERROR:$($_.Exception.Message) }"'

View File

@@ -1,3 +0,0 @@
# Minimal test - just echo to NAS
$r = & "C:\Program Files\OpenSSH\ssh.exe" -i C:\Users\sysadmin\.ssh\id_ed25519 -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new root@192.168.0.9 "echo MINIMAL_TEST_OK" 2>&1
Write-Host "Result: $r"

Some files were not shown because too many files have changed in this diff Show More