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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)}")
|
||||
@@ -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]}")
|
||||
@@ -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} ===")
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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!
|
||||
|
||||
=====================================
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"completed_index": 4431,
|
||||
"successes": 4431,
|
||||
"failures": []
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"total_attempted": 4431,
|
||||
"successes": 4431,
|
||||
"failures": 0,
|
||||
"failure_details": []
|
||||
}
|
||||
@@ -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": "fry’s 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.\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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]}")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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())}")
|
||||
@@ -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 ===')
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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]}")
|
||||
@@ -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
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"operation": "delete_blank_contacts",
|
||||
"total": 15,
|
||||
"successes": 15,
|
||||
"failures": 0,
|
||||
"failed_contacts": []
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 ""))
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
Access to this CIPP API endpoint is not allowed, the API Client does not have the required permission
|
||||
@@ -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.')
|
||||
@@ -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 ==="
|
||||
@@ -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 ==="
|
||||
@@ -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 ==="
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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')}")
|
||||
@@ -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()
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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}")
|
||||
@@ -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"]}')
|
||||
@@ -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 "$@"
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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) }"'
|
||||
@@ -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
Reference in New Issue
Block a user