- Ambiguous lookups (2+ contacts on one email) now dedupe by Contact Id
and fetch each candidate's full record so the picker shows
name + account + email instead of just the bare search payload.
- Per-row "תייק" button on each linked case lets the user pick the
correct case to file to when a contact has multiple cases; bottom
"תייק לתיק הזה" button is hidden in that scenario.
- "→ חזור לרשימת אנשי קשר" link restores the candidate list after
drilling into one of them.
- Contact name + case rows in the sidebar are now hyperlinks that
open the entity in Klear.
- AddInHost.LaunchFileToCaseAsync accepts an optional MatchResult and
auto-files when the sidebar resolved a single linked case, skipping
the manual search dialog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related improvements:
1. About tab → "בדוק עדכונים" button. Calls ClickOnce's
ApplicationDeployment.CurrentDeployment.CheckForDetailedUpdate +
Update on background threads, so the user can pull a new release
immediately without leaving Outlook — no PowerShell, no
CleanOnlineAppCache dance.
Status messages cover the four real outcomes:
• not deployed via ClickOnce (F5 / dev install) → explicit error
• on the latest version → green "already up to date"
• update available → progress text, then "installed — restart
Outlook"
• download / manifest / generic exception → red, with reason
2. Case folder resolution now matches the server. The previous client
logic used Case.name (the lawsuit title, e.g. "מונאס אריאל נ' ביטוח
לאומי") which produced "47-מונאס אריאל נ ביטוח לאומי". But
NetworkStorageIntegration's buildEntityFolderName uses the primary
contact's name from Case.contactsIds[0] → server actually creates
"47-אריאל מונאס" for the same case, so the two diverged and our
uploads went to a phantom folder.
New flow on case selection:
a) EnsureCaseSubfoldersAsync (creates server's canonical folders)
b) GetCase with select=contactsIds + networkStorageFolderPath
c) GetContactAsync on contactsIds[0] → grab Name
d) ResolveCaseFolderPath(case, contactName) → exact match
e) Cache result on vm.ResolvedCaseFolderPath; AddInHost reads it
instead of recomputing.
ResolveCaseFolderPath fallback order is now (1) the integration's
stored path, (2) <number>-<primary contact name>, (3) <number>-<case
name>, (4) the contact / case name alone — matching the server's
buildEntityFolderName logic step by step.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The About tab was always showing "1.0.0" because it read the DLL's
AssemblyVersion, which we never bumped in source — even when the
ClickOnce manifest had been rolled to 1.1.0 by the CI tag.
Read System.Deployment.Application.ApplicationDeployment.CurrentDeployment
.CurrentVersion at runtime when the add-in is running under ClickOnce
(IsNetworkDeployed=true). That's the version the user actually
installed. Fall back to AssemblyVersion when dev-running under F5 (no
ClickOnce context). Adds System.Deployment GAC reference to the UI
project.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server reported successful uploads for the two PDFs into case 6a03166d…
("מונאס אריאל") but the user couldn't find the files on the share. Both
LocalFilesystemClient::uploadFile and WebDavClient::uploadFile return
true only on a real write, so something downstream is silently dropping
the payload — most likely a WebDAV proxy or a basePath mismatch
between what NetworkStorageIntegration thinks the storage is vs. what
the user is browsing.
Add a verification step right after the upload:
1. POST /Attachment → attachmentId
2. POST /NetworkStorage/action/upload → 201 from server
3. GET /NetworkStorage/folderContents?path=<folder>
• If our filename is in the listing → INF "verify present"
• Else → WRN "expected X but NOT in listing. Currently in folder: […]"
The diagnostic line surfaces both halves of the puzzle in a single
log entry, so the next save attempt will tell us in plain text whether
the storage layer is the liar or the path is being normalised
somewhere unexpected.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After reading the NetworkStorageIntegration repo I realized the previous
"save attachments" flow was wrong on every level — Marcus-Law doesn't
use EspoCRM's stock Document/DocumentFolder model. Folders are real
filesystem paths under the network share, named after the Case (format
"<number>-<primary contact name>") with four standard subfolders:
מסמכים, אסמכתאות, התכתבויות, כללי.
Rewire the save pipeline against the integration's real endpoints
(documented in routes.json on the server repo):
Core — three new IEspoCrmClient methods:
- EnsureCaseSubfoldersAsync(caseId)
POST /NetworkStorage/action/createSubfolders
Idempotent — creates case folder + defaults if missing.
- ListNetworkStorageFolderAsync(path)
GET /NetworkStorage/folderContents?path=…
Returns folders + files; we filter to type=folder for the dropdown.
- UploadAttachmentToNetworkStorageAsync(attachmentId, targetPath)
POST /NetworkStorage/action/upload
Moves the staged Attachment binary onto the share at <path>/<name>.
CaseEntity gets a new `networkStorageFolderPath` field (set by the
integration's hook on first Document save; empty until then).
NetworkStorageItem DTO captures the folder-listing rows.
AttachmentSaveService rewritten — no more Document creation, no more
DocumentFolder picker. The new contract is:
SaveAsync(attachments, targetFolderPath)
Where targetFolderPath = "<case folder>[/subfolder]". For each
attachment: upload binary via standard /Attachment, then move via
NetworkStorage upload.
UI — SaveAttachmentsViewModel:
- Subfolder dropdown switched from DocumentFolder objects to strings.
- Static defaults loaded up-front:
"(שורש התיק)" + 4 server-side defaults.
- When the user picks a case:
1) hydrate Case (number + networkStorageFolderPath) via GetCase
2) call EnsureCaseSubfoldersAsync to materialise standard folders
3) listFolder at the case path to merge any custom subfolders into
the dropdown.
- ResolveCaseFolderPath: use Case.networkStorageFolderPath if set,
otherwise compute "<number>-<contact-name>" with a client-side
SanitizeFolderSegment that mirrors LocalFilesystemClient::sanitizeFileName
on the server (geresh/gershayim/quotes removed, illegal chars → hyphen).
AddInHost.LaunchSaveAttachmentsAsync:
- Composes <caseFolder>[/subfolder] from the VM choice.
- Pre-flight ensureCaseSubfolders as a safety net (in case the user
submitted before the dialog's lazy refresh completed).
- Summary MessageBox now shows the resolved storage path instead of a
bare entity name.
Tests: 39 passing. The build is clean across Core / UI / Tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the case-scoped query fails (e.g. tenant rejects parentId in
where), the ViewModel falls back to ListDocumentFoldersAsync — but a
success path with N=0 was silent, leaving the user wondering why the
dropdown stayed empty. Add an INF line so the log shows whether
N folders came back, so 0-folder tenants are obvious from a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs found by reading the log carefully — both shipped in the
initial attachments feature:
1. MailItemExtractor sent every attachment with ContentType = "" and
UploadAttachmentAsync defaulted that to "application/octet-stream".
EspoCRM's attachment upload whitelist (config
attachmentUpload.fileAcceptOptions) does not include octet-stream,
so the server replied:
403 (X-Status-Reason=Not allowed file type.)
Add a GuessMimeType(fileName) helper to MailItemExtractor that maps
common extensions (pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv,
images, archives, email, audio/video) to canonical MIMEs. The
octet-stream is now only used for truly unknown extensions.
2. 403 was being conflated with 401 in THREE places — EnsureSuccessAsync,
the inline UploadAttachmentAsync handler, and the inline
CreateDocumentAsync handler. Result: a single "Not allowed file
type" rejection threw EspoCrmAuthorizationException, which
AttachmentSaveService treats as a batch-level auth failure and
stops processing remaining files. (Same bug would have killed
FilingService and MatchingService for any non-auth 403 — e.g. an
ACL miss on one record stopping a multi-mail file.)
Only 401 is unambiguously an auth issue. 403 means "you ARE who
you say you are, but this specific operation isn't allowed" —
content rejected / ACL miss / role missing. Treat 403 as a normal
HTTP error: log the body + X-Status-Reason and surface the message
to the user without aborting the batch.
Also include the X-Status-Reason header in the generic
EspoCrmHttpException message everywhere, so any future "fileSizeTooBig"
or similar shows up in the user-visible error and not just the log.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server returned:
400 {"messageTranslation":{"label":"validationFailure",
"data":{"field":"publishDate","type":"required"}}}
Marcus-Law's EspoCRM install marks Document.publishDate as required.
By default the field is optional, but this tenant tightened it. Send
today's date (UTC, YYYY-MM-DD form) so the validation passes — the
user can still edit the published date in EspoCRM later if needed.
Also add the same verbose-error pattern to CreateDocumentAsync that we
already use on UploadAttachmentAsync: when the create returns non-2xx,
log the response body + X-Status-Reason header so any future required-
field mismatch shows up as a single readable line instead of
"שמירת הקבצים נכשלה".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the EspoCRM Attachment API doc (documentation.espocrm.com/development/api/attachment/),
the binary is sent in the "file" field as a data URI. The previous
extra "contents" alias was a legacy convention that strict tenants
reject with HTTP 400 (with an empty body, so the user only ever saw
"שמירת הקבצים נכשלה"). Drop "contents" — match the documented payload:
{
"name": "report.pdf",
"type": "application/pdf",
"role": "Attachment",
"relatedType": "Document",
"field": "file",
"file": "data:application/pdf;base64,..."
}
Folder dropdown: previously case-scoped only. The /Document filter by
parentId returned 400 on this tenant and case-scoped queries can be
fragile across versions. Now: try case-scoped first (so the most-
relevant folders surface); if it comes back empty or errors, fall back
to ListDocumentFoldersAsync (the full tenant list). The user always has
something to choose from, and the "(בלי תיקייה)" sentinel still works
as a "no folder" pick.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes for the save-attachments flow:
1. ListFoldersUsedInCaseAsync no longer hits /Case/{id}/documents — that
related-records endpoint requires a Case-to-Document link that
EspoCRM doesn't ship by default, so it returned an empty list on
every install. Switch to a direct Document list filtered by the
polymorphic parent fields:
GET /Document?where[0]=parentType=Case&where[1]=parentId=<id>
That returns all documents linked to the case regardless of how the
parent relationship is wired.
2. UploadAttachmentAsync now:
- Emits BOTH "contents" and "file" payload fields with the
data-URI base64 string. Different EspoCRM versions read from
different keys; including both makes the call portable.
- Logs the request shape (name, type, base64 length) before sending
so server-side rejects are easier to attribute.
- On a non-2xx response, logs the response body AND any
X-Status-Reason header (EspoCRM convention for short error codes
like "fileSizeIsTooLarge") so the user-visible exception finally
includes a useful reason instead of an empty body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The folder dropdown was showing every DocumentFolder in EspoCRM (global
list). The user expected "folders that exist in THIS case" — only the
folders already in use for the chosen case's documents.
Change: when the user picks a case in the SaveAttachments dialog, the
folder list refreshes by walking
GET /Case/{id}/documents?select=id,folderId,folderName&maxSize=200
and deduping the folderId values it finds. EspoCRM denormalizes
folderName onto Document, so no second lookup is needed.
If a case has no documents yet, the dropdown shows just the sentinel
"(בלי תיקייה)" — same default behavior as before, but no more
unrelated folders from other cases.
Files:
- Core: DocumentEntity gets FolderName; IEspoCrmClient and EspoCrmClient
get ListFoldersUsedInCaseAsync(caseId)
- UI: SaveAttachmentsViewModel re-fetches on SelectedCase change
(LoadFoldersForCaseAsync); AddInHost no longer calls the old
LoadFoldersAsync.
Tests: 39 still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual Studio rewrote the legacy VSTO csproj on its next save and
dropped the EmbeddedResource entry for the paperclip icon — without
it the new SaveAttachments ribbon button has no image at runtime and
the file isn't shipped in the assembly. Add it back next to the four
other Klear icons.
The new SaveAttachments button rendered but stayed greyed out. Cause:
getEnabled callbacks are evaluated once when the ribbon loads and the
result is cached — Office only re-asks if you explicitly invalidate
the control. The previous SelectionChange handler invalidated only
FileToEspoCrm.
Collected the selection-dependent button IDs into a static array and
made the SelectionChange handler invalidate all of them. Adding more
context-sensitive buttons in the future is now a one-line edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New workflow alongside "תייק ל-Klear": save just the attachments of a
mail to a Case in EspoCRM (no Email entity created), with an optional
DocumentFolder.
Core:
- AttachmentEntity, DocumentEntity, DocumentFolder DTOs
- IEspoCrmClient gets three new endpoints:
UploadAttachmentAsync (POST /Attachment, data-URI base64, role=Attachment)
CreateDocumentAsync (POST /Document, fileId + parent + folderId)
ListDocumentFoldersAsync (GET /DocumentFolder, returns envelope)
- AttachmentSaveService orchestrates per-attachment upload + Document
create, short-circuits on EspoCrmAuthorizationException, aggregates
per-file Saved/Failed/AuthRequired outcomes into AttachmentSaveSummary
UI:
- SaveAttachmentsDialog (RTL, card layout matching FileToCaseDialog):
Case search + folder dropdown + per-attachment checkbox list
- SaveAttachmentsViewModel: debounced GlobalSearch filtered to Case,
optional folder selector (with a "(no folder)" sentinel row),
Attachments collection of AttachmentRowViewModel rows
- AttachmentRowViewModel: IsSelected checkbox + display name + size
computed from base64 length
Host:
- AddInHost.LaunchSaveAttachmentsAsync(MailItem) — extracts on the STA
via MailItemExtractor, fetches folders, shows the dialog (anchored to
Outlook via AttachOwnerToOutlook), runs AttachmentSaveService on OK,
reports a Hebrew MessageBox summary
- AttachmentSaveService field on AddInHost + dispose hook
Ribbon:
- New "שמור קבצים" button in the Marcus-Law group, between Klear and
פרטי תיק. Enabled only when exactly one MailItem is selected and it
has at least one attachment
- klear-attach.png paperclip icon (32x32, blue rounded square),
embedded as a resource and resolved by the existing OnGetImage
callback (new case in the switch)
Tests: 39 still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
busybox wget in alpine resolves "localhost" to ::1 first, but our nginx
`listen 80` is IPv4-only, so the healthcheck got Connection refused and
Coolify marked the container running:unhealthy (which also made Traefik
skip its router and fall back to the lower-priority extension-platform
catch-all). Hard-coding 127.0.0.1 fixes that.
Also clear /usr/share/nginx/html before COPY so the base image's
50x.html/index.html don't leak into the published artifact set.
The VSTO MSBuild Publish target is the same one VS IDE uses internally and
handles build + assembly signing + manifest generation + manifest signing
+ .deploy renaming in one pass. Hand-stitching the steps with mage/signtool
on top of /t:Build was producing inconsistent state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build itself succeeded with 0 errors; the failing step was the Test step
which is non-critical for the release pipeline. Mark it as
continue-on-error so publish-clickonce + dockerize-and-deploy proceed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VSTO's ResolveKeySource target reads ManifestCertificateThumbprint
directly from csproj XML (not from /p: overrides) and looks for it
under Cert:\CurrentUser\My (not LocalMachine\My). Import the cert
to CurrentUser\My before build and patch the csproj XML to use the
real Marcus-Law thumbprint instead of VS's temporary-key thumbprint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two quality-of-life tweaks on the sidebar:
1. New CaseStatusToHebrewConverter translates standard EspoCRM Case
statuses (New / Assigned / Pending / Closed / Rejected / Duplicate /
In Progress / On Hold / Resolved / Cancelled / Closed Won / Closed
Lost / Open) to Hebrew. Unknown values pass through unchanged so
custom statuses configured per-tenant still render.
Wired in ReadingPaneView.xaml via Status binding.
2. MatchingService now pulls up to 50 cases per contact instead of 5.
The sidebar's ScrollViewer already handles long lists. 50 is well
below EspoCRM's default page size and large enough for any realistic
attorney/contact relationship. Section header changed from
"תיקים אחרונים" to "תיקים מקושרים" to reflect that it's the
complete list, not just the most recent.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VSTO refuses to build with SignManifests=false. Override the project's
hardcoded VS-temporary-key thumbprint with our real code-signing cert
(already imported into LocalMachine\My during runner setup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy VSTO project carries <SignManifests>true</SignManifests> with
a thumbprint pointing to VS's auto-generated temporary key, which exists
on a developer's machine but never on a CI runner. The release manifests
are signed properly in publish-clickonce via mage with the real Marcus-Law
cert, so /p:SignManifests=false at compile time is the right thing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NuGet ignores our service-level USERPROFILE override (it uses
SHGetKnownFolderPath which returns LocalSystem's profile regardless of
the env var) and installs packages to C:\Windows\system32\configsystemprofile\.nuget\packages. 32-bit MSBuild then can't find them because
the Win32 File System Redirector silently shunts system32 paths to
SysWOW64 for 32-bit processes -> NETSDK1064 'package not found'.
NUGET_PACKAGES is NuGet's highest-priority package-folder override and
puts everything outside system32, so both restore and build agree on
paths and the redirector never gets involved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cases lookup for "אברהם מוסטקי" was still failing after the
Case.number fix, with a new exception:
JsonException at $.list[0].createdAt
The JSON value is not in a supported DateTimeOffset format.
EspoCRM serializes datetimes as "2024-01-15 14:30:00" without a timezone
designator, but .NET's built-in DateTimeOffset reader only accepts
strict ISO 8601 with a tz suffix. Adding the FlexibleStringConverter
to CreatedAt is not enough since the target type is DateTimeOffset, not
string.
Solution: a dedicated FlexibleDateTimeOffsetConverter that:
- Accepts ISO 8601 with offset (fast path)
- Accepts "YYYY-MM-DD HH:mm:ss" / "YYYY-MM-DD" / variants, treating
them as UTC
- Accepts Unix epoch numbers
- Accepts null / empty strings
Registered globally on the JsonOptions used by EspoCrmClient, so every
model that exposes a DateTimeOffset? deserializes correctly without
per-property attributes.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone 'nuget.exe restore' (32-bit) and the subsequent build (which
loads the 64-bit .NET 9 SDK PackageDependencyResolution.targets) disagree
on the resolved package paths, surfacing as NETSDK1064 'Package ... was
not found' on SDK-style projects (OutlookAddin.UI / .Core / .Tests).
Using 'msbuild /restore' keeps both phases in one MSBuild process with a
consistent assets file and package cache lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VS Build Tools 2022 does not ship Microsoft.VisualStudio.Tools.Office.targets
(only the full VS IDE editions do), so the VSTO project fails to import them
(MSB4226). The runner machine already has VS Pro 2022 with the Office workload
installed; switch from microsoft/setup-msbuild@v2 (which picks Build Tools via
-latest) to a manual vswhere step that explicitly requires the Office workload.
Also removes the temporary filesystem-debug step (root cause was the Win32 File
System Redirector pulling system32 paths to SysWOW64 for 32-bit nuget/MSBuild;
fixed at the runner level via USERPROFILE redirect, not in the workflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues surfaced from the addin log:
1) The four Klear ribbon PNGs were not in the assembly manifest
(Available resources listed only the XMLs and Properties.Resources).
The EmbeddedResource entries for Images got dropped from
OutlookAddin.csproj during a previous rebase. Re-add them in the same
spot they had before.
2) MatchingService failed for one of the contacts with:
System.Text.Json.JsonException at $.list[0].number
Cannot get the value of a token type Number as a string.
EspoCRM emits Case.number as a JSON number on installs that use
auto-increment, and as a string when a custom case-code format is
configured. Add a small FlexibleStringConverter that reads either
token type into the same string field, and attach it to
CaseEntity.Number.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The self-hosted Windows runner runs as LocalSystem and only sees the
machine PATH. winget-installed pwsh lands in the user PATH and is
invisible to the service. Built-in Windows PowerShell 5.1 (`powershell`)
handles the same syntax we use, so switch all steps to it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the previous commit the ribbon rendered with NO icons at all.
Cause: loadImage on customUI is the Office-2007 pattern; Office 2010+
silently falls back to no image when the callback signature or timing
isn't exactly what it expects. Some hosts also cache a null result.
Switch to the more reliable per-button pattern:
- Each button declares getImage=OnGetImage (no image= attribute).
- OnGetImage(IRibbonControl control) maps control.Id -> embedded
PNG name and returns a fresh Bitmap.
- If a resource is missing we log it AND the full list of
GetManifestResourceNames() so we can diagnose any future mismatch
from the addin log alone.
Builds clean. PNGs unchanged; csproj <EmbeddedResource> entries are the
same as before. The previous OnLoadImage callback is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first v0.1.0 run failed because actions/checkout could not find Node
and msbuild/nuget were never on the LocalSystem PATH after winget install.
This commit pulls in the standard tool-setup actions and resolves the
.NET-Framework-bound tooling (mage.exe, signtool.exe) by walking the
Windows SDK / .NET 4.8 Developer Pack directories instead of hardcoding
versioned paths that drift across machines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>