cmoss3 commited on
Commit
7da71bc
·
1 Parent(s): 4162b4d

added dropbox, OCR routing logic, graph connection fix

Browse files
.claude/settings.local.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(php --version)",
5
+ "Bash(git --version)",
6
+ "Bash(docker --version)",
7
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://wordpress.localhost)",
8
+ "Bash(grep -v \"^$\")",
9
+ "PowerShell(Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in @\\(80, 443, 3306, 8080, 8443\\) } | Select-Object LocalAddress, LocalPort, OwningProcess)",
10
+ "PowerShell(winget --version 2>$null)",
11
+ "PowerShell(if \\($?\\) { \"winget: available\" } else { \"winget: not found\" })",
12
+ "PowerShell($r = Invoke-WebRequest -Uri \"http://wordpress.localhost\" -UseBasicParsing -ErrorAction SilentlyContinue; \"$\\($r.StatusCode\\) - $\\($r.Headers['Content-Type']\\)\")",
13
+ "PowerShell($env:PATH = \"C:\\\\xampp\\\\php;$env:PATH\"; wp --info 2>&1 | Select-Object -First 5)",
14
+ "Bash(xxd)",
15
+ "mcp__Claude_in_Chrome__tabs_context_mcp",
16
+ "Bash(xargs grep *)",
17
+ "Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"prompt\":\"What is your role?\",\"page\":\"sa-orchestration\"}' http://wordpress.localhost/wp-json/sa-orch/v1/sara/invoke)",
18
+ "Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"board_id\":1,\"topic\":\"jno-verification\"}' http://wordpress.localhost/wp-json/sa-orch/v1/seed/generate)"
19
+ ]
20
+ }
21
+ }
.env.example CHANGED
@@ -3,6 +3,9 @@ AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
3
  AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
4
  AZURE_CLIENT_SECRET=your-client-secret-here
5
 
 
 
 
6
  # Mail folder to monitor (top-level display name only; all others are ignored)
7
  MAIL_FOLDER_NAME=Inbox
8
 
@@ -13,14 +16,23 @@ NOTIFICATION_URL=https://your-hostname.example.com/webhook/notify
13
  # Graph subscription max lifetime in minutes (hard cap: 4230)
14
  SUBSCRIPTION_EXPIRY_MINUTES=4230
15
 
16
- # Server
17
  HOST=0.0.0.0
18
- PORT=8443
19
-
20
- # TLS certificate paths (mounted as a read-only volume into the container)
21
- TLS_CERT_FILE=/certs/server.crt
22
- TLS_KEY_FILE=/certs/server.key
23
 
24
  # Storage paths (volume-mounted)
25
  DATABASE_PATH=/data/rcmemail.db
26
  EMAIL_STORAGE_PATH=/emails
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
4
  AZURE_CLIENT_SECRET=your-client-secret-here
5
 
6
+ # UPN or object ID of the mailbox to monitor (required for app-only auth)
7
+ MAILBOX_USER=user@yourdomain.com
8
+
9
  # Mail folder to monitor (top-level display name only; all others are ignored)
10
  MAIL_FOLDER_NAME=Inbox
11
 
 
16
  # Graph subscription max lifetime in minutes (hard cap: 4230)
17
  SUBSCRIPTION_EXPIRY_MINUTES=4230
18
 
19
+ # Server (TLS is terminated by the hosting provider, not uvicorn)
20
  HOST=0.0.0.0
21
+ PORT=8000
 
 
 
 
22
 
23
  # Storage paths (volume-mounted)
24
  DATABASE_PATH=/data/rcmemail.db
25
  EMAIL_STORAGE_PATH=/emails
26
+
27
+ # Dropbox — confirm folder paths with ops/Hunter before going live
28
+ # App key + secret come from the Dropbox App Console (appinfo tab)
29
+ # Refresh token is generated once via scripts/dropbox_auth.py and stored here
30
+ DROPBOX_APP_KEY=your-app-key
31
+ DROPBOX_APP_SECRET=your-app-secret
32
+ DROPBOX_REFRESH_TOKEN=your-refresh-token
33
+ DROPBOX_PO_PATH=/RCM Supply/Inside Sales/POs
34
+ DROPBOX_INVOICE_PATH=/RCM Supply/Inside Sales/Invoices
35
+ DROPBOX_MTR_PATH=/RCM Supply/MTRs
36
+
37
+ # Processor poll interval in seconds
38
+ PROCESSOR_POLL_INTERVAL=10
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .pytest_cache/
CLAUDE.md CHANGED
@@ -44,10 +44,8 @@ This SleeperAgents-internal project (RcmEmailAutomation) sits at the SA-Orchestr
44
 
45
  ## ── REPO LAYOUT (root level) ────────────────────────────────────────────────
46
 
47
- > Paths below are **relative to this repo root** — wherever the developer cloned it. Drive letters and absolute paths from the seeding machine do not apply. See `README.md` → "Path discipline" before assuming any path.
48
-
49
  ```
50
- <repo-root>/ ← wherever the developer cloned RcmEmailAutomation
51
  ├── app/ ← Python FastAPI service (production target)
52
  │ ├── main.py
53
  │ ├── auth.py, config.py, database.py, email_processor.py
 
44
 
45
  ## ── REPO LAYOUT (root level) ────────────────────────────────────────────────
46
 
 
 
47
  ```
48
+ F:\SleeperAgents\RcmEmailAutomation\
49
  ├── app/ ← Python FastAPI service (production target)
50
  │ ├── main.py
51
  │ ├── auth.py, config.py, database.py, email_processor.py
Dockerfile CHANGED
@@ -1,7 +1,7 @@
1
  FROM python:3.12-slim
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
- build-essential \
5
  && rm -rf /var/lib/apt/lists/*
6
 
7
  WORKDIR /app
@@ -14,8 +14,8 @@ RUN chmod +x /entrypoint.sh
14
 
15
  COPY app/ ./app/
16
 
17
- RUN mkdir -p /data /emails /certs
18
 
19
- EXPOSE 8443
20
 
21
  ENTRYPOINT ["/entrypoint.sh"]
 
1
  FROM python:3.12-slim
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ build-essential tesseract-ocr poppler-utils \
5
  && rm -rf /var/lib/apt/lists/*
6
 
7
  WORKDIR /app
 
14
 
15
  COPY app/ ./app/
16
 
17
+ RUN mkdir -p /data /emails
18
 
19
+ EXPOSE 8000
20
 
21
  ENTRYPOINT ["/entrypoint.sh"]
ONBOARDING.md CHANGED
@@ -64,14 +64,13 @@ Run this audit before installing or running anything. Surface findings to the hu
64
 
65
  ### Audit checklist
66
 
67
- 1. **Local stack capability** — does this machine already have a local PHP + MySQL + WordPress capability? **Where the developer's stack lives is a Phase 0 question** (see Phase 0 above) — do NOT assume a default install location. Common patterns the audit may discover:
68
- - XAMPP (Windows: typically `C:\xampp`; sometimes installed on a different drive — confirm with the developer, never assume)
69
- - Local by Flywheel (Windows: `C:\Users\<user>\Local Sites`; macOS: `~/Local Sites`)
70
- - Laragon (Windows: typically `C:\laragon` — confirm)
71
- - MAMP (macOS: `/Applications/MAMP`; Windows: `C:\MAMP`)
72
- - Docker Desktop with WordPress containers (any path; ask)
73
  - Any custom Apache/Nginx + PHP-FPM + MySQL combination
74
- - **The developer may have none of the above** — JNO must respect that and use what they have or what they explicitly approve installing. Do not install on a drive or location the developer has not authorized.
75
 
76
  2. **PHP version available** — `php --version` (target: 7.4+, 8.1+ preferred)
77
 
@@ -145,43 +144,49 @@ After activating each, walk its setup wizard to completion. Skip any optional in
145
  - FluentCRM: `wp_fc_*` tables exist (subscribers, campaigns, etc.). Admin page renders.
146
  - Fluent Support: `wp_fs_tickets` table exists. Admin page at `?page=fluent-support` renders. **Watch for the prefix-orphan trap** — if tables ended up at a non-standard prefix (e.g., from a cloned DB), that's a separate fix not part of fresh JNO.
147
 
148
- ### Step 5 — Install SA-Orchestration plugin (pre-staged in this repo)
149
 
150
- **The plugin source is already in this repo at `<repo-root>/sa-orchestration/`** no GitHub clone is needed. Operational doctrine MD files are at `<repo-root>/SA-orchestration MD/` for agent reference.
151
 
152
- Copy (or symlink) the pre-staged plugin tree into the local WP install:
 
 
 
 
153
 
154
  ```
155
- cp -r <repo-root>/sa-orchestration <wp-content>/plugins/sa-orchestration
156
  ```
157
 
158
- Adjust path syntax for the developer's OS. On Windows: use the WP install's `wp-content\plugins\` path. The dev's Claude resolves `<repo-root>` and `<wp-content>` from the developer's actual environment.
159
 
160
  **Verify**: `<wp-install>/wp-content/plugins/sa-orchestration/sa-orchestration.php` exists.
161
 
162
- If the pre-staged `sa-orchestration/` directory is missing for some reason, **stop and surface to the human**. Do not invent a clone URL. The seed should always include the plugin tree at `<repo-root>/sa-orchestration/`; absence indicates a corrupted seed, not a missing dependency.
163
-
164
  ### Step 6 — Place the Asterion canonical corpus
165
 
166
  The SA-Orchestration plugin reads its canonical content from a filterable path. For local JNO, point that path at the corpus copy in this repo.
167
 
168
  Two valid placements:
169
 
170
- **Locked approach: mirror corpus + install pre-staged mu-plugin.**
171
 
172
  ```
173
- # (a) Mirror the canonical corpus into the WP install
174
  mkdir -p <wp-install>/wp-content/sa-asterion
175
  cp -r <this-repo>/asterion/* <wp-install>/wp-content/sa-asterion/
176
-
177
- # (b) Install the pre-staged mu-plugin (already in this repo at <repo-root>/mu-plugins/sa-orch-spec-root.php)
178
- mkdir -p <wp-install>/wp-content/mu-plugins
179
- cp <this-repo>/mu-plugins/sa-orch-spec-root.php <wp-install>/wp-content/mu-plugins/sa-orch-spec-root.php
180
  ```
181
 
182
- The pre-staged mu-plugin points the `sa_orch_asterion_spec_root` filter at `WP_CONTENT_DIR . '/sa-asterion'`. **Do not author it from a snippet** the file exists at `<repo-root>/mu-plugins/sa-orch-spec-root.php`. If missing, the seed is corrupt — surface to the human.
 
 
 
 
 
 
 
 
 
183
 
184
- Earlier seed versions described a "Path B" pointing the filter at the repo's `asterion/` directly without mirroring. That fails on container-based stacks (DDEV) where the container only mounts `public_html/`. The mirror approach above works on every stack.
185
 
186
  **Verify**: filesystem readable by the web user; `01-ontology.md` and `02-invariants.md` exist at the chosen location.
187
 
@@ -311,68 +316,6 @@ Stop. Wait for human direction.
311
 
312
  ---
313
 
314
- ### Step 14 — Configure Remote Roots (post-acceptance, optional but recommended)
315
-
316
- SA-Orchestration v0.8.0 ships a new admin page: **SA-Orchestration → Remote Roots**. It connects this leaf install to a remote SA-Orchestration root (e.g., the dev root at `sleeperagendev.wpenginepowered.com` or a per-client root) via leaf-initiated polling. The leaf fetches Fluent Boards tasks where the authenticated user is an assignee on the root.
317
-
318
- **Doctrine**: leaf-initiated, always up; the root never reaches into the leaf.
319
-
320
- #### One-time setup
321
-
322
- 1. On the **root** install:
323
- - Visit `<root>/wp-admin/profile.php`
324
- - Scroll to **Application Passwords** → enter name (e.g. `"<leaf-host> leaf"`) → **Add New Application Password** → copy the value (one-time display)
325
-
326
- 2. On the **leaf** (this install):
327
- - Open: `<wp-install>/wp-admin/admin.php?page=sa-orchestration-remote-roots`
328
- - **Root URL**: e.g. `https://sleeperagendev.wpenginepowered.com`
329
- - **App Username**: the WP login on the root (typically the user's email)
330
- - **App Password**: paste the value from step 1
331
- - **Save Remote Root**
332
-
333
- 3. Click **Fetch Now**.
334
-
335
- #### The MVP loop
336
-
337
- 1. On the root's Fluent Boards, self-assign to any task.
338
- 2. Back on this leaf, click **Fetch Now**.
339
- 3. The new assignment appears in the table.
340
-
341
- #### Out of MVP scope (deferred to v0.8.1+)
342
-
343
- - Multi-root (only ONE root configurable in MVP)
344
- - Adaptive polling cadence (manual fetch only in MVP; OS-level cron is Board #41)
345
- - Fluent Support ticket auto-creation (just a list in MVP)
346
- - Curated upstream replies (one-way down only in MVP)
347
-
348
- ---
349
-
350
- ### Updating SA-Orchestration after source changes
351
-
352
- When the SA-Orchestration source repo ships an update (new plugin classes, bug fixes, doctrine v1.x), this repo's pre-staged `sa-orchestration/` tree is rolled forward by the seed maintainer in a separate commit. There is **no auto-update push**; updates propagate manually:
353
-
354
- ```
355
- SA-Orch source → seed repos (maintainer commits) → your local clone (git pull) → your live WP (re-deploy)
356
- ```
357
-
358
- To pull a source update into your live local WP install:
359
-
360
- ```
361
- # 1. Pull latest pre-staged plugin tree
362
- cd <repo-root>
363
- git pull
364
-
365
- # 2. Re-deploy to local WP install
366
- cp -r sa-orchestration/* <wp-install>/wp-content/plugins/sa-orchestration/
367
-
368
- # 3. Verify
369
- # Visit /wp-admin/admin.php?page=sa-orchestration to confirm new admin pages.
370
- ```
371
-
372
- Adjust path syntax for the developer's OS. **Note**: WP plugin "deactivate then reactivate" is NOT required for source-tree updates that don't add database tables. If a new schema migration ships, run a one-time deactivate→activate cycle.
373
-
374
- ---
375
-
376
  ## Rollback (if a step fails)
377
 
378
  - **Step 1–3 fail**: WP itself isn't operational. Investigate the local stack; not an SA-Orch concern.
 
64
 
65
  ### Audit checklist
66
 
67
+ 1. **Local stack capability** — does this machine already have a local PHP + MySQL + WordPress capability? Check for:
68
+ - XAMPP at `C:\xampp` or `F:\XAMPP` or similar
69
+ - Local by Flywheel at `C:\Users\<user>\Local Sites`
70
+ - Laragon at `C:\laragon`
71
+ - MAMP at `/Applications/MAMP` (macOS) or equivalent
72
+ - Docker Desktop with WordPress containers
73
  - Any custom Apache/Nginx + PHP-FPM + MySQL combination
 
74
 
75
  2. **PHP version available** — `php --version` (target: 7.4+, 8.1+ preferred)
76
 
 
144
  - FluentCRM: `wp_fc_*` tables exist (subscribers, campaigns, etc.). Admin page renders.
145
  - Fluent Support: `wp_fs_tickets` table exists. Admin page at `?page=fluent-support` renders. **Watch for the prefix-orphan trap** — if tables ended up at a non-standard prefix (e.g., from a cloned DB), that's a separate fix not part of fresh JNO.
146
 
147
+ ### Step 5 — Clone SA-Orchestration plugin source
148
 
149
+ Verify the canonical SA-Orchestration repository URL with the human first. The likely location:
150
 
151
+ ```
152
+ git clone https://github.com/SleeperAgents/SA-Orchestration.git /tmp/sa-orchestration-src
153
+ ```
154
+
155
+ Then copy the plugin tree into the local WP install:
156
 
157
  ```
158
+ cp -r /tmp/sa-orchestration-src/plugin/sa-orchestration <wp-content>/plugins/sa-orchestration
159
  ```
160
 
161
+ Adjust path syntax for the dev's OS. On Windows: use the WP install's `wp-content\plugins\` path.
162
 
163
  **Verify**: `<wp-install>/wp-content/plugins/sa-orchestration/sa-orchestration.php` exists.
164
 
 
 
165
  ### Step 6 — Place the Asterion canonical corpus
166
 
167
  The SA-Orchestration plugin reads its canonical content from a filterable path. For local JNO, point that path at the corpus copy in this repo.
168
 
169
  Two valid placements:
170
 
171
+ **A. Mirror corpus into WP content** (recommended):
172
 
173
  ```
 
174
  mkdir -p <wp-install>/wp-content/sa-asterion
175
  cp -r <this-repo>/asterion/* <wp-install>/wp-content/sa-asterion/
 
 
 
 
176
  ```
177
 
178
+ **B. Point the spec_root filter at this repo's `asterion/` directory directly** (no copy):
179
+
180
+ Drop a mu-plugin at `<wp-install>/wp-content/mu-plugins/sa-orch-spec-root.php`:
181
+
182
+ ```php
183
+ <?php
184
+ add_filter( 'sa_orch_asterion_spec_root', function () {
185
+ return 'F:/SleeperAgents/RcmEmailAutomation/asterion';
186
+ }, 5 );
187
+ ```
188
 
189
+ Choose A for cleanliness (corpus lives inside the WP install). Choose B for "single source of truth" (corpus stays in this repo, the WP install reads through). Either is acceptable for JNO.
190
 
191
  **Verify**: filesystem readable by the web user; `01-ontology.md` and `02-invariants.md` exist at the chosen location.
192
 
 
316
 
317
  ---
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  ## Rollback (if a step fails)
320
 
321
  - **Step 1–3 fail**: WP itself isn't operational. Investigate the local stack; not an SA-Orch concern.
README.md CHANGED
@@ -32,20 +32,7 @@ CR ⊇ CA ⊇ JourneySeeker ⊇ MissionNet/MeshNet ⊇ SA-Orchestration ⊇ Bran
32
 
33
  Causal Relativity, Causal Agentics, JourneySeeker, the Compass Doctrine, the Story Seed pattern, and the Quest-vs-Job distinction are pre-existing intellectual property authored by **Mark Holak**. They are preserved under Invariant **I9** of the SA-Orchestration spec pack. **You may reference these concepts. You may not re-author them as if they originated at the implementation layer or at the agent layer.** When you cite them, cite them.
34
 
35
- The full canonical corpus lives at **`asterion/` in this repo** (copied here at seed time from the SA-Orchestration source of truth that lives in a separate SleeperAgents-internal repository — the absolute path on the seeding machine is not relevant to the developer's machine). Read it when you encounter a term you are uncertain about.
36
-
37
- ### Path discipline (read once; never violate)
38
-
39
- This seed was authored on a different machine than the developer's. **Any absolute path that may appear in `ONBOARDING.md`, in this README, or in code excerpts is illustrative — drawn from the seeding machine — not canonical for the developer's environment.** Drive letters, root paths, and OS conventions on the seeding machine do not transfer.
40
-
41
- Specifically:
42
-
43
- - **Do not** install anything onto a drive letter, mount point, or path the developer has not declared.
44
- - **Do not** assume any directory exists outside this repo's tree without verifying or asking.
45
- - **Always** map paths through the developer's stated layout (Phase 0 surfaces this).
46
- - **When in doubt about where something goes, ask.** The developer is the arbiter of their domain (see Phase 0 in `ONBOARDING.md`). Asking is cheaper than installing into the wrong place.
47
-
48
- If the developer has not declared a drive layout in Phase 0, **ask explicitly before any install action**. Path-related questions are the kind that quietly pass when the agent assumes — and quietly destroy when the assumption is wrong.
49
 
50
  ### Doctrine carry-forward (the operating discipline)
51
 
 
32
 
33
  Causal Relativity, Causal Agentics, JourneySeeker, the Compass Doctrine, the Story Seed pattern, and the Quest-vs-Job distinction are pre-existing intellectual property authored by **Mark Holak**. They are preserved under Invariant **I9** of the SA-Orchestration spec pack. **You may reference these concepts. You may not re-author them as if they originated at the implementation layer or at the agent layer.** When you cite them, cite them.
34
 
35
+ The full canonical corpus lives at `asterion/` in this repo (copied from the SA-Orchestration source of truth at `F:/SleeperAgents/SA-orchestration/asterion/`). Read it when you encounter a term you are uncertain about.
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  ### Doctrine carry-forward (the operating discipline)
38
 
app/classifier.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ _KEYWORD_MAP = (
9
+ ("mtr", ("material test report", "mill test report", "mtr")),
10
+ ("po", ("purchase order", "p.o.", "purchase ord")),
11
+ ("invoice", ("invoice", "inv #", "inv#")),
12
+ ("quote", ("quotation", "quote", "rfq", "request for quote", "bid")),
13
+ )
14
+
15
+ DocType = str # 'po' | 'invoice' | 'mtr' | 'quote' | 'unknown'
16
+
17
+
18
+ def classify(subject: str, filename: str, file_bytes: bytes) -> DocType:
19
+ subject_lower = subject.lower() if subject else ""
20
+ filename_lower = filename.lower() if filename else ""
21
+
22
+ if "mtr" in subject_lower:
23
+ return "mtr"
24
+ if "mtr" in filename_lower:
25
+ return "mtr"
26
+
27
+ text = _ocr_pdf(file_bytes)
28
+ if not text:
29
+ return "unknown"
30
+
31
+ sample = text[:500].lower()
32
+
33
+ for doc_type, keywords in _KEYWORD_MAP:
34
+ if any(kw in sample for kw in keywords):
35
+ return doc_type
36
+
37
+ return "unknown"
38
+
39
+
40
+ def _ocr_pdf(file_bytes: bytes) -> str:
41
+ try:
42
+ from pdf2image import convert_from_bytes
43
+ import pytesseract
44
+ except ImportError:
45
+ logger.warning("pdf2image or pytesseract not installed; OCR unavailable")
46
+ return ""
47
+
48
+ try:
49
+ images = convert_from_bytes(file_bytes, first_page=1, last_page=2)
50
+ except Exception:
51
+ logger.exception("pdf2image conversion failed")
52
+ return ""
53
+
54
+ parts: list[str] = []
55
+ for img in images:
56
+ try:
57
+ parts.append(pytesseract.image_to_string(img))
58
+ except Exception:
59
+ logger.exception("pytesseract OCR failed on page")
60
+ return "\n".join(parts)
app/config.py CHANGED
@@ -9,18 +9,25 @@ class Settings(BaseSettings):
9
  azure_client_id: str
10
  azure_client_secret: SecretStr
11
 
 
12
  mail_folder_name: str
13
  notification_url: HttpUrl
14
  subscription_expiry_minutes: int = 4230
15
 
16
  host: str = "0.0.0.0"
17
- port: int = 8443
18
- tls_cert_file: Path
19
- tls_key_file: Path
20
 
21
  database_path: Path = Path("/data/rcmemail.db")
22
  email_storage_path: Path = Path("/emails")
23
 
 
 
 
 
 
 
 
 
24
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
25
 
26
 
 
9
  azure_client_id: str
10
  azure_client_secret: SecretStr
11
 
12
+ mailbox_user: str
13
  mail_folder_name: str
14
  notification_url: HttpUrl
15
  subscription_expiry_minutes: int = 4230
16
 
17
  host: str = "0.0.0.0"
18
+ port: int = 8000
 
 
19
 
20
  database_path: Path = Path("/data/rcmemail.db")
21
  email_storage_path: Path = Path("/emails")
22
 
23
+ dropbox_app_key: str
24
+ dropbox_app_secret: SecretStr
25
+ dropbox_refresh_token: SecretStr
26
+ dropbox_po_path: str = "/RCM Supply/Inside Sales/POs"
27
+ dropbox_invoice_path: str = "/RCM Supply/Inside Sales/Invoices"
28
+ dropbox_mtr_path: str = "/RCM Supply/MTRs"
29
+ processor_poll_interval: int = 10
30
+
31
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
32
 
33
 
app/database.py CHANGED
@@ -32,6 +32,17 @@ CREATE TABLE IF NOT EXISTS notifications (
32
  error_message TEXT,
33
  storage_path TEXT
34
  );
 
 
 
 
 
 
 
 
 
 
 
35
  """
36
 
37
 
@@ -140,6 +151,45 @@ async def update_notification_status(
140
  await conn.commit()
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  if __name__ == "__main__":
144
  import asyncio
145
 
 
32
  error_message TEXT,
33
  storage_path TEXT
34
  );
35
+
36
+ CREATE TABLE IF NOT EXISTS routing_results (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ notification_id INTEGER NOT NULL,
39
+ processed_at TEXT NOT NULL DEFAULT (datetime('now')),
40
+ attachment_filename TEXT,
41
+ document_type TEXT,
42
+ action TEXT,
43
+ destination_path TEXT,
44
+ error_message TEXT
45
+ );
46
  """
47
 
48
 
 
151
  await conn.commit()
152
 
153
 
154
+ async def get_fetched_notifications(conn: aiosqlite.Connection) -> list[dict]:
155
+ cursor = await conn.execute(
156
+ "SELECT * FROM notifications WHERE processing_status = 'fetched' ORDER BY received_at"
157
+ )
158
+ rows = await cursor.fetchall()
159
+ return [dict(r) for r in rows]
160
+
161
+
162
+ async def set_notification_routing_status(
163
+ conn: aiosqlite.Connection, notif_id: int, status: str
164
+ ) -> None:
165
+ await conn.execute(
166
+ "UPDATE notifications SET processing_status = ? WHERE id = ?",
167
+ (status, notif_id),
168
+ )
169
+ await conn.commit()
170
+
171
+
172
+ async def log_routing_result(
173
+ conn: aiosqlite.Connection,
174
+ notification_id: int,
175
+ filename: str | None,
176
+ doc_type: str | None,
177
+ action: str,
178
+ destination_path: str | None = None,
179
+ error_message: str | None = None,
180
+ ) -> None:
181
+ await conn.execute(
182
+ """
183
+ INSERT INTO routing_results
184
+ (notification_id, attachment_filename, document_type, action,
185
+ destination_path, error_message)
186
+ VALUES (?, ?, ?, ?, ?, ?)
187
+ """,
188
+ (notification_id, filename, doc_type, action, destination_path, error_message),
189
+ )
190
+ await conn.commit()
191
+
192
+
193
  if __name__ == "__main__":
194
  import asyncio
195
 
app/dropbox_client.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import dropbox
6
+ from dropbox.files import WriteMode
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class DropboxClient:
12
+ def __init__(self, app_key: str, app_secret: str, refresh_token: str) -> None:
13
+ self._dbx = dropbox.Dropbox(
14
+ app_key=app_key,
15
+ app_secret=app_secret,
16
+ oauth2_refresh_token=refresh_token,
17
+ )
18
+
19
+ def upload(self, file_bytes: bytes, dest_path: str) -> str:
20
+ """Upload bytes to dest_path. Returns the path Dropbox assigned (may differ on collision)."""
21
+ result = self._dbx.files_upload(
22
+ file_bytes,
23
+ dest_path,
24
+ mode=WriteMode.add,
25
+ autorename=True,
26
+ )
27
+ return result.path_display
app/email_processor.py CHANGED
@@ -4,6 +4,7 @@ from pathlib import Path
4
 
5
  import aiosqlite
6
 
 
7
  from app.database import update_notification_status
8
  from app.graph_client import GraphClient
9
  from app.storage import write_email
@@ -22,7 +23,7 @@ async def fetch_and_store(
22
  ) -> Path:
23
  try:
24
  email_data = await client.get(
25
- f"/me/messages/{message_id}",
26
  params={"$expand": "attachments", "$select": (
27
  "id,subject,from,toRecipients,ccRecipients,receivedDateTime,"
28
  "body,uniqueBody,hasAttachments,attachments,webLink"
@@ -60,4 +61,4 @@ async def fetch_and_store(
60
  async def _fetch_large_attachment(
61
  client: GraphClient, message_id: str, attachment_id: str
62
  ) -> bytes:
63
- return await client.get_raw(f"/me/messages/{message_id}/attachments/{attachment_id}/$value")
 
4
 
5
  import aiosqlite
6
 
7
+ from app.config import settings
8
  from app.database import update_notification_status
9
  from app.graph_client import GraphClient
10
  from app.storage import write_email
 
23
  ) -> Path:
24
  try:
25
  email_data = await client.get(
26
+ f"/users/{settings.mailbox_user}/messages/{message_id}",
27
  params={"$expand": "attachments", "$select": (
28
  "id,subject,from,toRecipients,ccRecipients,receivedDateTime,"
29
  "body,uniqueBody,hasAttachments,attachments,webLink"
 
61
  async def _fetch_large_attachment(
62
  client: GraphClient, message_id: str, attachment_id: str
63
  ) -> bytes:
64
+ return await client.get_raw(f"/users/{settings.mailbox_user}/messages/{message_id}/attachments/{attachment_id}/$value")
app/folder_resolver.py CHANGED
@@ -3,8 +3,8 @@ from app.graph_client import GraphClient
3
  _BASE = "https://graph.microsoft.com/v1.0"
4
 
5
 
6
- async def resolve_folder_id(client: GraphClient, display_name: str) -> str:
7
- path = "/me/mailFolders"
8
  params: dict = {"$top": 100}
9
 
10
  while path:
 
3
  _BASE = "https://graph.microsoft.com/v1.0"
4
 
5
 
6
+ async def resolve_folder_id(client: GraphClient, display_name: str, mailbox_user: str) -> str:
7
+ path = f"/users/{mailbox_user}/mailFolders"
8
  params: dict = {"$top": 100}
9
 
10
  while path:
app/main.py CHANGED
@@ -52,7 +52,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator:
52
  app.state.settings = settings
53
  app.state.subscription_id = None
54
 
55
- folder_id = await resolve_folder_id(client, settings.mail_folder_name)
56
  app.state.folder_id = folder_id
57
  logger.info("Monitoring folder '%s' (id=%s)", settings.mail_folder_name, folder_id)
58
 
 
52
  app.state.settings = settings
53
  app.state.subscription_id = None
54
 
55
+ folder_id = await resolve_folder_id(client, settings.mail_folder_name, settings.mailbox_user)
56
  app.state.folder_id = folder_id
57
  logger.info("Monitoring folder '%s' (id=%s)", settings.mail_folder_name, folder_id)
58
 
app/processor.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone attachment routing process.
2
+
3
+ Run with: python -m app.processor
4
+
5
+ Polls the SQLite DB for notifications with processing_status='fetched', classifies
6
+ each attachment via OCR, and uploads routable documents to Dropbox.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import signal
14
+ import sys
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from app.classifier import classify
19
+ from app.config import settings
20
+ from app.database import (
21
+ get_db,
22
+ get_fetched_notifications,
23
+ log_routing_result,
24
+ set_notification_routing_status,
25
+ )
26
+ from app.dropbox_client import DropboxClient
27
+
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="%(asctime)s [processor] %(levelname)s %(message)s",
31
+ )
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _ROUTABLE = {"po", "invoice", "mtr"}
35
+
36
+ _DEST_MAP = {
37
+ "po": settings.dropbox_po_path,
38
+ "invoice": settings.dropbox_invoice_path,
39
+ "mtr": settings.dropbox_mtr_path,
40
+ }
41
+
42
+ _shutdown = asyncio.Event()
43
+
44
+
45
+ def _handle_signal(*_: object) -> None:
46
+ logger.info("Shutdown signal received")
47
+ _shutdown.set()
48
+
49
+
50
+ async def _process_notification(
51
+ dbx: DropboxClient,
52
+ notif: dict,
53
+ ) -> None:
54
+ notif_id: int = notif["id"]
55
+ storage_path = notif.get("storage_path")
56
+ if not storage_path:
57
+ logger.warning("Notification %d has no storage_path; skipping", notif_id)
58
+ async with get_db(settings.database_path) as conn:
59
+ await set_notification_routing_status(conn, notif_id, "routing_error")
60
+ await log_routing_result(
61
+ conn, notif_id, None, None, "error",
62
+ error_message="no storage_path recorded",
63
+ )
64
+ return
65
+
66
+ meta_path = Path(storage_path) / "metadata.json"
67
+ try:
68
+ meta = json.loads(meta_path.read_text())
69
+ except Exception as exc:
70
+ logger.error("Cannot read metadata.json for notification %d: %s", notif_id, exc)
71
+ async with get_db(settings.database_path) as conn:
72
+ await set_notification_routing_status(conn, notif_id, "routing_error")
73
+ await log_routing_result(
74
+ conn, notif_id, None, None, "error",
75
+ error_message=f"metadata read failed: {exc}",
76
+ )
77
+ return
78
+
79
+ subject: str = meta.get("subject", "")
80
+ received_dt: str = meta.get("received_datetime", "")
81
+ date_prefix = _date_prefix(received_dt)
82
+ attachments: list[dict] = meta.get("attachments", [])
83
+
84
+ uploaded_count = 0
85
+ final_status = "routing_skipped"
86
+
87
+ for att in attachments:
88
+ filename: str = att.get("name", "attachment")
89
+ att_path = Path(storage_path) / "attachments" / filename
90
+
91
+ try:
92
+ file_bytes = att_path.read_bytes()
93
+ except Exception as exc:
94
+ logger.error("Cannot read attachment %s: %s", att_path, exc)
95
+ async with get_db(settings.database_path) as conn:
96
+ await log_routing_result(
97
+ conn, notif_id, filename, None, "error",
98
+ error_message=f"file read failed: {exc}",
99
+ )
100
+ final_status = "routing_error"
101
+ continue
102
+
103
+ doc_type = classify(subject, filename, file_bytes)
104
+ logger.info("Notification %d / %s → %s", notif_id, filename, doc_type)
105
+
106
+ if doc_type in _ROUTABLE:
107
+ dest_folder = _DEST_MAP[doc_type]
108
+ month_folder = received_dt[:7] if received_dt else "unknown"
109
+ dest_path = f"{dest_folder}/{month_folder}/{date_prefix}_{filename}"
110
+ try:
111
+ actual_path = await asyncio.to_thread(dbx.upload, file_bytes, dest_path)
112
+ logger.info("Uploaded %s → %s", filename, actual_path)
113
+ async with get_db(settings.database_path) as conn:
114
+ await log_routing_result(
115
+ conn, notif_id, filename, doc_type, "uploaded",
116
+ destination_path=actual_path,
117
+ )
118
+ uploaded_count += 1
119
+ final_status = "routed"
120
+ except Exception as exc:
121
+ logger.error("Dropbox upload failed for %s: %s", filename, exc)
122
+ async with get_db(settings.database_path) as conn:
123
+ await log_routing_result(
124
+ conn, notif_id, filename, doc_type, "error",
125
+ error_message=f"upload failed: {exc}",
126
+ )
127
+ final_status = "routing_error"
128
+ else:
129
+ async with get_db(settings.database_path) as conn:
130
+ await log_routing_result(conn, notif_id, filename, doc_type, "skipped")
131
+
132
+ async with get_db(settings.database_path) as conn:
133
+ await set_notification_routing_status(conn, notif_id, final_status)
134
+ logger.info("Notification %d → %s", notif_id, final_status)
135
+
136
+
137
+ def _date_prefix(received_dt: str) -> str:
138
+ try:
139
+ return datetime.fromisoformat(received_dt).strftime("%Y%m%d")
140
+ except Exception:
141
+ return "00000000"
142
+
143
+
144
+ async def run() -> None:
145
+ dbx = DropboxClient(
146
+ app_key=settings.dropbox_app_key,
147
+ app_secret=settings.dropbox_app_secret.get_secret_value(),
148
+ refresh_token=settings.dropbox_refresh_token.get_secret_value(),
149
+ )
150
+ logger.info(
151
+ "Processor started; polling every %ds", settings.processor_poll_interval
152
+ )
153
+
154
+ while not _shutdown.is_set():
155
+ try:
156
+ async with get_db(settings.database_path) as conn:
157
+ pending = await get_fetched_notifications(conn)
158
+
159
+ if pending:
160
+ logger.info("Found %d notification(s) to process", len(pending))
161
+ for notif in pending:
162
+ async with get_db(settings.database_path) as conn:
163
+ await set_notification_routing_status(
164
+ conn, notif["id"], "routing_queued"
165
+ )
166
+
167
+ for notif in pending:
168
+ await _process_notification(dbx, notif)
169
+ except Exception:
170
+ logger.exception("Unhandled error in processor loop")
171
+
172
+ try:
173
+ await asyncio.wait_for(
174
+ _shutdown.wait(), timeout=settings.processor_poll_interval
175
+ )
176
+ except asyncio.TimeoutError:
177
+ pass
178
+
179
+ logger.info("Processor stopped")
180
+
181
+
182
+ def main() -> None:
183
+ loop = asyncio.new_event_loop()
184
+ asyncio.set_event_loop(loop)
185
+
186
+ for sig in (signal.SIGTERM, signal.SIGINT):
187
+ loop.add_signal_handler(sig, _handle_signal)
188
+
189
+ try:
190
+ loop.run_until_complete(run())
191
+ finally:
192
+ loop.close()
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
docker-compose.yml CHANGED
@@ -3,9 +3,8 @@ services:
3
  build: .
4
  env_file: .env
5
  ports:
6
- - "8443:8443"
7
  volumes:
8
- - ./certs:/certs:ro
9
  - rcmemail-data:/data
10
  - rcmemail-emails:/emails
11
  restart: unless-stopped
 
3
  build: .
4
  env_file: .env
5
  ports:
6
+ - "8000:8000"
7
  volumes:
 
8
  - rcmemail-data:/data
9
  - rcmemail-emails:/emails
10
  restart: unless-stopped
entrypoint.sh CHANGED
@@ -3,8 +3,10 @@ set -e
3
 
4
  python -m app.database --init
5
 
 
 
 
 
6
  exec uvicorn app.main:app \
7
  --host "${HOST:-0.0.0.0}" \
8
- --port "${PORT:-8443}" \
9
- --ssl-certfile "${TLS_CERT_FILE}" \
10
- --ssl-keyfile "${TLS_KEY_FILE}"
 
3
 
4
  python -m app.database --init
5
 
6
+ python -m app.processor &
7
+ PROCESSOR_PID=$!
8
+ trap "kill $PROCESSOR_PID 2>/dev/null; wait $PROCESSOR_PID 2>/dev/null" EXIT TERM INT
9
+
10
  exec uvicorn app.main:app \
11
  --host "${HOST:-0.0.0.0}" \
12
+ --port "${PORT:-8000}"
 
 
requirements.txt CHANGED
@@ -5,3 +5,7 @@ httpx==0.27.0
5
  pydantic-settings==2.3.0
6
  apscheduler==3.10.4
7
  aiosqlite==0.20.0
 
 
 
 
 
5
  pydantic-settings==2.3.0
6
  apscheduler==3.10.4
7
  aiosqlite==0.20.0
8
+ dropbox==12.0.2
9
+ pdf2image==1.17.0
10
+ pytesseract==0.3.13
11
+ Pillow==10.4.0
scripts/dropbox_auth.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-time Dropbox OAuth setup — generates the refresh token for .env.
2
+
3
+ Usage:
4
+ python scripts/dropbox_auth.py
5
+
6
+ What it does:
7
+ 1. Prints an authorization URL — open it in a browser and click Allow.
8
+ 2. Paste the auth code back at the prompt.
9
+ 3. Prints the refresh token to copy into DROPBOX_REFRESH_TOKEN in your .env.
10
+
11
+ After this runs once the refresh token lives in .env and no further browser
12
+ interaction is ever needed. The app uses it silently at runtime.
13
+
14
+ Requirements: pip install dropbox
15
+ """
16
+ import sys
17
+
18
+ try:
19
+ import dropbox
20
+ from dropbox import DropboxOAuth2FlowNoRedirect
21
+ except ImportError:
22
+ sys.exit("dropbox package not installed — run: pip install dropbox")
23
+
24
+
25
+ def main() -> None:
26
+ app_key = input("Enter DROPBOX_APP_KEY: ").strip()
27
+ app_secret = input("Enter DROPBOX_APP_SECRET: ").strip()
28
+
29
+ auth_flow = DropboxOAuth2FlowNoRedirect(
30
+ app_key,
31
+ app_secret,
32
+ token_access_type="offline",
33
+ )
34
+
35
+ authorize_url = auth_flow.start()
36
+ print("\n--- Step 1 ---")
37
+ print("Open this URL in a browser and click Allow:\n")
38
+ print(authorize_url)
39
+ print()
40
+
41
+ auth_code = input("--- Step 2 --- Paste the authorization code here: ").strip()
42
+
43
+ try:
44
+ result = auth_flow.finish(auth_code)
45
+ except Exception as exc:
46
+ sys.exit(f"Auth failed: {exc}")
47
+
48
+ print("\n--- Done ---")
49
+ print("Add these to your .env file:\n")
50
+ print(f"DROPBOX_APP_KEY={app_key}")
51
+ print(f"DROPBOX_APP_SECRET={app_secret}")
52
+ print(f"DROPBOX_REFRESH_TOKEN={result.refresh_token}")
53
+ print()
54
+ print("The refresh token does not expire. Re-run this script only if you revoke app access.")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()