Sidebar lookups threw ObjectDisposedException on the EspoCrmClient's
HttpClient after any settings save. TryInitializeCrm rebuilds the CRM
stack (new HttpClient + MatchingService) and disposes the old HttpClient,
but the long-lived sidebar ReadingPaneViewModel had captured the old
MatchingService and kept calling into the disposed client.
ReadingPaneViewModel now resolves the MatchingService live on each lookup
via a Func<IMatchingService?> provider (() => host.MatchingService), so
it always uses the current stack and degrades gracefully (shows "not
configured") when none exists. SidebarController no longer captures a
snapshot.
Also: rename the Settings dialog's bottom button from "ביטול" to "סגור".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v1.2.15's tree picker built the whole tree inside an async method and
mutated the TreeView-bound ObservableCollections after an await. In this
VSTO host the WPF SynchronizationContext is not installed, so
ConfigureAwait(true) resumed on a thread-pool thread and FolderTree.Add
threw "changes to its SourceCollection from a thread different from the
Dispatcher thread" — the exception was swallowed and the tree showed
empty (no folders selectable at all).
Capture the UI Dispatcher in the view-model and marshal every bound
collection mutation (FolderTree + each node's Children) through it, so
the tree populates regardless of which thread the network await resumes
on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1.2.15
Folder picker: replace the flat subfolder dropdown with a lazy-loading
TreeView so the user can drill into nested subfolders to any depth.
New FolderNodeViewModel lists each folder's children on first expand;
the case-root node is selected/expanded by default and seeded with the
default subfolders (kept even if the share listing fails). ChosenSubfolderName
now returns a multi-segment relative path, appended to the case folder
as before.
Message boxes: route all of AddInHost's MessageBox calls through a new
ShowMessage helper that anchors a transient top-most owner window to the
foreground Outlook window. A bare MessageBox has no owner once our dialog
closes, so it sank behind Outlook and the user never saw the success/error
result.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The EspoCRM tenant now rejects role=Attachment uploads that carry
neither `field` nor `parentType` (400 "No `field` and `parentType`."),
which broke the entire save-attachments flow on v1.2.13 — every file,
including PDFs, failed at Step 1.
Bind the upload to the documented Attachment-Multiple example
parentType=Note, field=attachments. Note.attachments is a built-in
field with no allowed-file-types restriction, so all types pass
(including images) — unlike Document.file, which rejected images with
403. No parentId is set; the blob stays standalone and is still just
pushed to the network share.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes to the save-attachments flow:
1. Preselect the identified client case. When the mail sender already
maps to a CRM contact, the "שמור קבצים" dialog now seeds its case
list from the sidebar match (cached lookup) instead of opening empty
— single linked case auto-selects (and resolves the target folder);
several show for the user to pick. Search still overrides.
2. Save image attachments. EspoCRM's Document.file field is configured
to accept document types only, so JPEG/PNG uploads were rejected with
403 "Not allowed file type". The save-attachments flow never creates a
Document — it only pushes the blob to the network share — so we now
POST the Attachment unbound (no relatedType=Document/field=file),
skipping the per-field accept check and letting every type through.
3. Save from an open message. Adds a "שמור קבצים" button to the read-mail
inspector ribbon (Microsoft.Outlook.Mail.Read) so the user no longer
has to return to the message list to file attachments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v1.2.11 shipped a broken publish.tar.gz: the Rename-Item that turns
`MarcusLaw.OutlookAddin.vsto` into the canonical `OutlookAddin.vsto`
failed silently (script ran with $ErrorActionPreference = 'Continue'),
so the docker image had no OutlookAddin.vsto at the expected path and
the CDN 404'd on the auto-update URL. Gitea Generic packages are
immutable, so 1.2.11 cannot be republished.
Restrict the .vsto lookup to publish/ root (Get-ChildItem -File, no
-Recurse) so the inner versioned manifest under Application Files is
never picked, switch the rename to -ErrorAction Stop, and assert that
publish/OutlookAddin.vsto exists before continuing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walk Case.contactsIds for the first contact with a non-empty emailAddress
(falling back to the Account's emailAddress) instead of blindly using
contactsIds[0] and giving up if it has no email — Outlook was opening
the new mail with no To recipient when the first linked contact was
e.g. opposing counsel or a witness without a stored address.
Also hydrate the "כתוב מתיק" picker rows with number/status/court#
through GetCaseAsync (same pattern as SaveAttachmentsViewModel) and
switch the dialog to the DataGrid layout so it matches the file-
attachments picker the user expected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates after the day-long updater overhaul:
- The csproj no longer has <UpdateInterval>7</UpdateInterval>; v1.2.9
switched to <UpdatePeriodically>false</UpdatePeriodically>, so the
VSTO runtime checks the .vsto manifest on every Outlook startup.
Both PROJECT-BRIEFING.md ("How releases work") and
AUTO-UPDATE-SETUP.md (section 5) now describe the on-startup model
instead of the weekly-poll model that was never operationally true.
- AUTO-UPDATE-SETUP.md also gets two new notes that came out of today:
what the "בדוק עדכונים" button can and can't do (informational only,
pointing at the in-process-update impossibility memo), and the
one-time uninstall+reinstall required when a developer's machine has
a dev-cert install that can't auto-update across to the prod cert.
No code change; no tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No code change. After v1.2.9 set UpdatePeriodically=false, the VSTO
runtime should hit the .vsto manifest on every Outlook launch. From a
clean v1.2.9 install: close Outlook, reopen, About tab shows 1.2.10
with no clicks anywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Why v1.2.7→v1.2.8 didn't auto-install on Outlook restart: the manifest
was published with <UpdateInterval>7</UpdateInterval> + units=days, which
tells the VSTO runtime "don't even check the manifest URL more often
than once a week." MinimumRequiredVersion (set by CI to the same tag
version) would have forced the install — but only AFTER the check runs,
and the check was throttled to weekly.
Replace UpdateInterval/UpdateIntervalUnits with UpdatePeriodically=false.
This omits the <expiration> element from the deployment manifest, which
ClickOnce/VSTO interprets as "check before every host start." The extra
HTTP GET on the .vsto manifest at Outlook launch is ~6KB, ~100ms — a
non-issue for startup time, vs. the previous behavior where new
releases sat unused on platform.dev for up to a week per user.
From v1.2.9 onward, tag → CI publishes → next Outlook launch on each
lawyer's PC installs it. This is what we wanted the auto-update chain
to look like all along.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No code change. Confirms that on a clean v1.2.7 install (correct
simplified updater), pressing "בדוק עדכונים" reports the new version
honestly, and the VSTO runtime auto-installs v1.2.8 on the next
Outlook startup with no errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.2.6 fired VSTOInstaller.exe /I directly. It returned 0x8007007E
(ERROR_MOD_NOT_FOUND) — the loaded add-in DLLs are file-locked by the
running Outlook, so even the canonical installer can't overwrite them
while the host is up. This caps a series of failed attempts:
- 1.2.0/1/2: ApplicationDeployment.Update() → TrustNotGrantedException
- 1.2.3/4: Process.Start(.vsto URL, shell) → Chrome downloads to
local-machine zone → InvalidDeploymentException
- 1.2.5/6: VSTOInstaller.exe /I <url> → 0x8007007E file lock
There is no API that updates a running VSTO add-in. The VSTO runtime's
own auto-update path (check manifest on Outlook startup, install before
loading) is the only mechanism that works — and it already does, every
time the user restarts Outlook.
So the button now just detects the new version via manifest HTTP GET
and displays "זמינה גרסה X. סגור ופתח את Outlook — הגרסה החדשה תותקן
אוטומטית בעלייה." No install attempt, no spawned process, no zone or
trust dance. The status line is honest about what the user has to do.
Removed the now-unused FindVstoInstaller registry-walk helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No code change. Confirms that the v1.2.5 updater (VSTOInstaller.exe /I)
bypasses the Chrome download trap and pops the install dialog directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.2.3 changed the updater to Process.Start the .vsto URL with
UseShellExecute=true. On any browser-as-default-handler (Chrome / Edge)
that downloads the file to disk instead of handing it to VSTOInstaller,
and re-opening the local copy then fails with InvalidDeploymentException
"לפריסה וליישום אין אזורי אבטחה תואמים" — the downloaded file lives in
the Local Machine zone while the manifest declares the Internet zone.
The reliable path is to invoke VSTOInstaller.exe directly with /I and
the manifest URL. The URL is then processed in the Internet zone,
matching what's declared in the manifest. FindVstoInstaller() looks at
the canonical VSTO 4.0 path and falls back to the HKCR registration of
the .vsto extension. If even that fails, we tell the user to close and
reopen Outlook — the VSTO runtime picks up the new manifest on its own
during startup, so the worst case is just an extra restart, not a stuck
install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No code change. Confirms that the v1.2.3 updater (HTTP GET the manifest
+ Process.Start the .vsto URL) reaches VSTOInstaller cleanly without
the TrustNotGrantedException that the old ApplicationDeployment.Update
path always threw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Log shows TrustNotGrantedException thrown inside CheckForDetailedUpdate
itself (ApplicationTrust.RequestTrust → DetermineTrustCore →
DetermineTrust). The misleading "המשתמש סירב להעניק את ההרשאות"
fires even when the user accepted the trust dialog on install and the
publisher cert is unchanged: ApplicationDeployment.CheckForDetailedUpdate
re-runs the TrustManager evaluation, which can't show a UAC-style
prompt from inside a hosted Outlook process and falls through to a
denial exception.
Bypass the ClickOnce update API entirely:
1. Read CurrentVersion from ApplicationDeployment (this part is fine)
2. HTTP GET the .vsto manifest at UpdateLocation, parse the top-level
<assemblyIdentity> version
3. Compare versions
4. If newer, Process.Start the .vsto URL with UseShellExecute=true so
Windows hands it to VSTOInstaller.exe — which is the only thing that
actually knows how to update a running VSTO add-in correctly
ApplicationDeployment.Update() is also dropped because it has the same
in-process limitation as the trust check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No code change. Bumping to confirm a fresh install from platform.dev
picks up the next tag-driven release cleanly via "בדוק עדכונים".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two %APPDATA% / %LOCALAPPDATA% paths in Settings → אודות are now
hyperlinks. Click to open the folder (creates it lazily if it's not
there yet, so the link works before the first log is written).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the stale "Things deliberately deferred" entries (CI runner,
codesign cert, platform.dev hosting, Mattermost webhook) with a new
"How releases work (current)" section that captures what's actually
running today:
- Production install URL = platform.dev.marcus-law.co.il/outlook-addin/
- Tag-driven Gitea Actions pipeline in .gitea/workflows/build.yml
- Real codesign cert lives in Gitea secrets, dev cert in repo
- Klear visible rebrand boundary: what shipped in v1.2.0 vs what stays
MarcusLaw to preserve auto-update across the 10 installed lawyer PCs
- The dropped Publish/ folder was never the production channel
The "deferred" table now only mentions the identity-level Layer-2
rebrand, which is the only thing actually still open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-facing strings only — ClickOnce identity, AssemblyName, namespaces,
DPAPI entropy and on-disk paths all stay MarcusLaw so the 10 lawyers
auto-update without any manual uninstall/reinstall.
What lawyers see change:
- About panel: dark-navy header card (#121d2e from KlearBranding) with
the Klear logo (32KB PNG mirrored from
espocrm-extensions/KlearBranding/files/client/custom/img/logo-klear.png)
and the title "Klear" instead of "Marcus-Law OutlookAddin"
- All MessageBox titles ("Klear" instead of "Marcus-Law OutlookAddin")
- Ribbon group label ("Klear" instead of "Marcus-Law") in both the
Explorer and the new-message Inspector ribbons
- Startup-failure dialog ("תוסף Klear נכשל" instead of "תוסף Marcus-Law")
What stays MarcusLaw (deliberate — Layer 1 only):
- ClickOnce app identity → preserves auto-update across this release
- %APPDATA%\MarcusLaw\…\settings.json + creds.dat → no migration needed
- DpapiCredentialStore EntropySeed → API keys remain decryptable
- Internal namespaces / pipe name / AssemblyCompany attribute
Cleanup:
- Removed the entire Publish/ directory from git. It was the orphaned
manual-release channel under gitea raw/branch/main/Publish/ that no
one actually uses — production installs are served from
platform.dev.marcus-law.co.il via the Gitea Actions pipeline. The
pipeline writes to a local publish/ workspace and uploads a tar.gz
to the generic registry; it never reads or writes the repo's Publish/.
- Wiped the local LegalCRM_1_0_*_0 leftovers from the failed rename
experiment.
Bumped to 1.2.0 — MINOR because the rebrand is a user-visible change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs reported after v1.1.2:
1. False-negative "נכשלו" toast even though the file was on disk. Root
cause from the log: Polly's 15s timeout on /NetworkStorage/action/upload
fires while the server is still writing larger PDFs, so the client sees
TimeoutRejectedException → Outcome.Failed, while the server quietly
finishes the write. The outcome is now decided by listing the target
folder, not by the upload response. If the upload exception fires, the
listing is retried up to 4 times with 2s delays to let the server
finish. Saved if the file appears, Failed only if it never does.
2. The filename TextBox in the attachments list exposed the extension
(.pdf / .docx). Users have to delete it manually when renaming and
risk dropping it. AttachmentRowViewModel now splits FileName into
BaseName (editable) and Extension (frozen at construction). The XAML
binds the TextBox to BaseName and shows the Extension as a muted
adjacent chip. The on-disk filename = BaseName + Extension always.
Bump to 1.1.3.0 for the patch release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DataGrid case picker now shows status via CaseStatusToHebrewConverter
(added PendingHearing / PendingDecision mappings)
- Attachment row replaces the read-only filename TextBlock with a TwoWay
TextBox so users can rename a file before saving (flows through
EspoAttachment.Name into the upload + the on-disk path)
- SaveAttachmentsViewModel.IsLoadingSubfolders gates the folder ComboBox
while EnsureCaseSubfolders / GetCase / ListNetworkStorageFolder run,
so users can't pick a stale "(שורש התיק)" before the real list arrives
- About tab: ResolveDisplayVersion now reads from the host VSTO assembly
(*.OutlookAddin) instead of the UI assembly. The UI csproj is SDK-style
with no <Version> so it always reported 1.0.0.0, which leaked into the
About line on dev installs
- Bump to 1.1.2.0 (csproj + AssemblyInfo) to match the v1.1.2 tag that
will trigger the Gitea Actions auto-publish to platform.dev
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Versioning policy: MAJOR.MINOR.PATCH stored as MAJOR.MINOR.PATCH.0
in the ClickOnce manifest (it requires 4 components). The trailing
.0 is hidden by SettingsViewModel.ResolveDisplayVersion so the About
tab shows "1.0.7" not "1.0.7.0".
AutoIncrementApplicationRevision is now false; every published change
must bump <ApplicationVersion> and AssemblyVersion/AssemblyFileVersion
together by hand. MAJOR bumps need explicit owner approval.
Previously AssemblyVersion was pinned at 1.0.0.0 while ClickOnce
auto-bumped only ApplicationVersion, so the deployed DLL's
FileVersion never matched the manifest. Now they're locked at 1.0.7.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single-line ListBox with a DataGrid (case number, name,
court case number, status) so users disambiguate same-named cases
(e.g. two "שלומוב זויה" cases). Hydrates each search hit via
GET /Case/{id}?select=id,name,number,status,cCourtCaseNumber in
parallel so columns populate without an extra click. CourtCaseNumber
is mapped to the EspoCRM custom field cCourtCaseNumber.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The install failed with "the file MarcusLaw.OutlookAddin.dll.config has
a computed hash different from the one specified in the manifest" because
git's core.autocrlf=true normalized the dll.config.deploy file from CRLF
(as VS wrote it) to LF in the index. End users downloading via setup.exe
from the Gitea raw URL got the LF version, whose SHA-1 differs from the
hash recorded in dll.manifest at publish time.
Add a .gitattributes that:
- marks Publish/** as -text (no normalization, ever)
- belt-and-braces marks *.deploy / *.manifest / *.vsto / *.application
/ *.pfx as binary globally
git add --renormalize re-staged dll.config.deploy with its real CRLF
bytes, so the hash in the manifest now matches the file on Gitea.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- InstallUrl switched from \192.168.10.97 LAN share to
https://gitea.dev.marcus-law.co.il/.../raw/branch/main/Publish/
so end-users (including remote ones over VPN) can install and
auto-update from the Gitea repo over HTTPS.
- IsWebBootstrapper set to True now that setup.exe fetches over HTTP.
- .gitignore: re-include Publish/** despite the *.vsto/*.application/
*.deploy/*.manifest wildcards so the ClickOnce distribution root
can be tracked.
- First publish of the bundle (version 1.0.0.5); older 1_0_0_2..4
folders deleted before commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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.