This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
chaim cf10db00a3 chore: scaffold Outlook VSTO Add-in for EspoCRM
Initial repo scaffold for a Windows VSTO/COM Outlook Add-in (C#/.NET FW 4.8)
that integrates Outlook desktop with EspoCRM via the existing MailRouter
v1.2.0 endpoint (POST /api/v1/MailRouter/file).

Includes:
- Solution + 4 csproj projects (host, Core, UI, Tests) under src/
- VSTO host project stub (ThisAddIn.cs, requires VS 2022 + Office workload to fully resolve)
- Core/UI/Tests as SDK-style net48 projects (NuGet: Polly, System.Text.Json,
  Serilog, CommunityToolkit.Mvvm, xUnit, Moq, FluentAssertions)
- .gitea/workflows/build.yml for Windows runner CI (publish/sign placeholders)
- docs/ARCHITECTURE.md (component diagram, threading model, per-feature flow)
- docs/ONBOARDING.md (Hebrew lawyer-facing install guide)

Server-side already in place: MailRouter v1.2.0 — see plan
~/.claude/plans/resilient-sauteeing-feather.md for the 6-week implementation
plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:05:07 +00:00

7.9 KiB

OutlookAddin — Architecture

תוסף VSTO/COM ל-Outlook Desktop שמדבר עם EspoCRM. ראה גם resilient-sauteeing-feather.md לתכנית המלאה.

Component diagram

┌──────────────────────────────────────────────────────────────────────┐
│  Outlook Desktop (Win, Office 2019/2021/M365)                        │
│                                                                       │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  MarcusLaw.OutlookAddin (.dll, VSTO host)                       │ │
│  │  ├── Ribbon (Explorer + Inspector)                              │ │
│  │  ├── CustomTaskPane (Reading-pane sidebar)                      │ │
│  │  ├── EventHandlers (ItemSend, ItemAdd, SelectionChange)         │ │
│  │  └── UrlProtocol (outlookaddin:// listener via named pipe)      │ │
│  └────────────────────────────────────────────────────────────────┘ │
│             │ uses ↓                                                  │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  MarcusLaw.OutlookAddin.UI (WPF dialogs + VMs)                  │ │
│  │  FileToCaseDialog · SettingsDialog · ComposeFromCaseDialog      │ │
│  │  ReadingPaneView · LocalizationManager · RTL themes             │ │
│  └────────────────────────────────────────────────────────────────┘ │
│             │ uses ↓                                                  │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  MarcusLaw.OutlookAddin.Core (pure .NET 4.8, Office-free)       │ │
│  │  EspoCrmClient (HttpClient + Polly)                             │ │
│  │  FilingService · MatchingService · DiskRetryQueue               │ │
│  │  DpapiCredentialStore · SettingsManager · LoggerFactory         │ │
│  └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────────────┘
                           │ HTTPS
                           ↓
              ┌────────────────────────────┐
              │  EspoCRM (PHP)             │
              │  + MailRouter v1.2.0       │
              │  POST /MailRouter/file     │
              │  GET  /EmailAddress/search │
              │  GET  /GlobalSearch        │
              │  GET  /Case, /Account, ... │
              └────────────────────────────┘

Threading model

Outlook COM is STA. The single Outlook UI thread must own every COM object reference. Background work (HTTP, base64 encoding) must run on the threadpool, and any callback that touches Outlook must marshal back via the captured SynchronizationContext.

The golden rule: never touch a COM object from inside a Task.Run continuation. Either:

  • Read primitive values (string, int, byte[]) from the COM object on the UI thread first, pass those to Task.Run, or
  • Use await syncContext.PostAsync(() => mailItem.Categories = ...) to marshal back.

Globals.ThisAddIn.Application is captured in ThisAddIn_Startup and exposed via IOutlookAccess wrapper.

Per-feature flow

File to EspoCRM (feature 1)

User clicks Ribbon "File to EspoCRM"
  → FileToCaseDialog opens (modal, WPF, RTL)
  → User searches: GET /GlobalSearch?q=... debounced 250ms
  → User picks target, dialog returns (parentType, parentId)
  → FilingService.FileAsync(MailItem[], target) on threadpool:
      for each MailItem:
        - extract on STA: subject, from, to/cc/bcc, body, dateSent
        - extract PR_INTERNET_MESSAGE_ID via PropertyAccessor
        - for each Attachment: SaveAsFile → read bytes → base64 → delete temp
        - POST /MailRouter/file (Polly retries)
        - 200/201 → marshal back: MailItem.Categories += "Filed: <name>"
        - 409 → success (already filed)
        - 401 → modal "re-enter API key"
        - else → DiskRetryQueue
  → Toast: "Filed 3 of 4 emails. 1 queued."

Reading-pane sidebar (feature 2)

Explorer.SelectionChange → debounce 200ms
  → ExplorerSelectionHandler grabs MailItem.SenderEmailAddress
  → MatchingService.LookupAsync(sender):
      1. GET /EmailAddress/search?q=<sender>&entityType=Contact
      2. If 1 hit: GET /Account/<contactId.accountId>
                    GET /Case?where[linkedWith]=contacts:<contactId>
      3. cache 10 min in MemoryCache
  → ReadingPaneViewModel updates → WPF binding refreshes sidebar

Compose from Case (feature 3)

A. Outlook → EspoCRM:
   Inspector Ribbon "Compose from Case"
     → ComposeFromCaseDialog (filtered to Case)
     → GET /Case/<id>?select=name,number,contactsIds,accountId
     → set MailItem.To, .Subject, append HTMLBody footer
     → stamp UserProperty EspoCaseId = caseId, GUID = guid

B. EspoCRM → Outlook:
   User clicks "Email this case" in EspoCRM UI
     → outlookaddin://compose?caseId=<id>
     → OS launches OutlookAddinProtocolHandler.exe
     → handler writes to \\.\pipe\MarcusLaw.OutlookAddin
     → Add-in pipe listener reproduces flow A

C. ItemSend:
   MailItem sent → handler reads UserProperty EspoCaseId
   → watch Sent Items via Items.ItemAdd for matching GUID
   → when found: FilingService.FileAsync(item, case)

Auto-file folders (feature 4)

Settings → Folders tab: TreeView of mailbox folders, checkboxes
  Each watched: default parent, mode (auto-file / notify), threshold

FolderWatcher (singleton):
  - Loads (StoreId, FolderPath, EntryID) tuples from settings.json
  - For each: store Items reference in static field (CRITICAL — prevents GC)
  - Subscribe Items.ItemAdd

On ItemAdd:
  - Capture EntryID immediately
  - Check HashSet<EntryID> dedup
  - Queue work to STA-bound TaskScheduler
  - MatchingService.LookupAsync(sender)
  - 1 hit + auto-mode → FilingService.FileAsync
  - else → Categories += "Needs filing" + toast

Deployment

ClickOnce hosted at https://platform.dev.marcus-law.co.il/outlook-addin/. Auto-update on Outlook launch. Self-signed cert deployed to all 10 lawyer machines via GPO (Trusted Publishers + Trusted Root CA).

ראה resilient-sauteeing-feather.md § Deployment לפרטים מלאים.

Repository conventions

  • Conventional commits (fix:, feat:, chore:, docs:)
  • Branches: main (protected, CI green required), feature branches feat/<short-name>
  • Versioning: SemVer, ClickOnce auto-rebuilds on every push to main, formal releases tagged v1.0.0, v1.1.0, ...
  • Task Master: each issue/feature corresponds to a task-master task. תהליך פר-extension כפי שב-~/espocrm-extensions/CLAUDE.md.