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>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
# OutlookAddin — Architecture
|
||||
|
||||
תוסף VSTO/COM ל-Outlook Desktop שמדבר עם EspoCRM. ראה גם [`resilient-sauteeing-feather.md`](../../.claude/plans/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`](../../.claude/plans/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`.
|
||||
Reference in New Issue
Block a user