Compare commits

...

6 Commits

Author SHA1 Message Date
PointStar 35ce8facff fix(attachments): bind upload to Note.attachments to satisfy EspoCRM
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>
2026-06-09 12:28:22 +03:00
PointStar c685f4f91d publish: 1.2.13 — preselect sender case, save images, save-from-open-mail
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>
2026-05-31 13:49:16 +03:00
PointStar 5eae3da213 publish: 1.2.12 — harden VSTO rename + reissue broken v1.2.11 release
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>
2026-05-26 15:31:14 +03:00
PointStar 8264c11b57 publish: 1.2.11 — compose-from-case email + picker columns fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:56:32 +03:00
PointStar 4556d2c1b3 fix(compose-from-case): use any contact with email + show number/status
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>
2026-05-26 14:56:18 +03:00
PointStar a8e947c825 docs: reflect v1.2.9 auto-update settings + v1.2.7 button behavior
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>
2026-05-24 21:14:00 +03:00
13 changed files with 310 additions and 72 deletions
+13 -7
View File
@@ -258,15 +258,21 @@ jobs:
& msbuild @msbuildArgs & msbuild @msbuildArgs
if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" } if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" }
# Sanity check: did the VSTO publish target produce a .vsto file? # Sanity check + rename to canonical OutlookAddin.vsto so the URL
$vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1 # matches PROVIDER_URL (hardcoded to .../OutlookAddin.vsto). Restrict
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" } # to publish/ root -- there's a second .vsto inside Application Files
# that must keep its versioned name. Verify the renamed file exists
# before continuing; v1.2.11 shipped broken because a silent failure
# here left no OutlookAddin.vsto in the tar and 404'd the CDN.
$vsto = Get-ChildItem -File -Filter '*.vsto' -Path publish | Select-Object -First 1
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found at publish/ root" }
Write-Host " produced $($vsto.FullName)" Write-Host " produced $($vsto.FullName)"
# Rename to canonical OutlookAddin.vsto so the ClickOnce manifest
# URL matches PROVIDER_URL (which is hardcoded to .../OutlookAddin.vsto).
if ($vsto.Name -ne 'OutlookAddin.vsto') { if ($vsto.Name -ne 'OutlookAddin.vsto') {
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto' Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto' -ErrorAction Stop
Write-Host " renamed -> OutlookAddin.vsto"
}
if (-not (Test-Path 'publish/OutlookAddin.vsto')) {
throw "post-rename: publish/OutlookAddin.vsto does not exist"
} }
Write-Host "=== STEP F: stage protocol handler ===" Write-Host "=== STEP F: stage protocol handler ==="
+5 -10
View File
@@ -315,16 +315,11 @@ git push origin v1.0.1
ה-CI ירוץ אוטומטית כמו בשחרור הראשון. ה-CI ירוץ אוטומטית כמו בשחרור הראשון.
**אצל הלקוחות:** **אצל הלקוחות:**
- ClickOnce בודק עדכון אוטומטית **כל 7 ימים** (הגדרה ב-csproj: `UpdateInterval=7`). - ה־VSTO runtime בודק את ה-`.vsto` manifest **בכל פתיחה של Outlook** (משוחזר מ-`<UpdatePeriodically>false</UpdatePeriodically>` ב-csproj החל מ-v1.2.9; קודם זה היה פעם ב-7 ימים). הבדיקה היא GET של ~6KB, ~100ms — בלתי מורגש בזמן הטעינה.
- כדי **להאיץ** עדכון אצל לקוח מסויים: - ה-CI מציב `/p:MinimumRequiredVersion=<tag>` בכל build, אז מיד כשהבדיקה מזהה גרסה חדשה — ההתקנה נכפית בלי דיאלוג.
1. סגור את Outlook לגמרי. - **תרגום מעשי**: 30 שניות אחרי `git push --tags` ה-CI מסיים → תוך 2-3 דקות החבילה נדחפת ל-CDN → בכניסה הבאה של כל אחד מהלקוחות ל-Outlook הוא רץ על הגרסה החדשה. **בלי שום פעולה ידנית מצידם**.
2. פתח את Outlook שוב. - **הכפתור "בדוק עדכונים" בהגדרות** רק מודיע ("זמינה גרסה X — סגור ופתח Outlook"). הוא לא יכול להתקין מתוך תהליך Outlook רץ — `VSTOInstaller.exe` לא יכול לדרוס DLLs נעולים, וה-ApplicationDeployment API נכשל ב-`TrustNotGrantedException` בהקשר VSTO. ההתקנה תמיד קורית רק דרך ה-runtime בעלייה של Outlook. [פירוט בזיכרון: feedback_vsto_updater_api.md]
3. אם עברו לפחות 7 ימים מבדיקת ה-update הקודמת — יבדוק עכשיו ויעדכן. - **תקלה נדירה — לקוח שהותקן ידנית עם dev cert** (`HP-OFFICE1\<user>`, publicKeyToken `41d8d795775ea8cb`) לא יכול לעבור auto-update לגרסת prod cert (`Marcus-Law OutlookAddin`, publicKeyToken `c68d2b4c25051c5b`) — ClickOnce מתייחס אליהן כשתי אפליקציות שונות. **פעם אחת**: Apps & Features → uninstall של MarcusLaw.OutlookAddin → התקנה חדשה מ-`https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`. אחרי זה auto-update עובד.
4. אם לא — אפשר לנקות את ה-ClickOnce cache:
```powershell
rundll32.exe dfshim.dll,CleanOnlineAppCache
```
ואז לפתוח שוב את `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`.
--- ---
+1 -1
View File
@@ -284,7 +284,7 @@ If the session is on the Linux dev server (`/home/chaim/espocrm-extensions/Outlo
- Uploads `publish.tar.gz` to Gitea generic registry (`outlook-addin-publish/<version>/publish.tar.gz`) - Uploads `publish.tar.gz` to Gitea generic registry (`outlook-addin-publish/<version>/publish.tar.gz`)
- Linux job downloads the tar.gz, builds a Docker image `outlook-addin-cdn:latest`, pushes to Gitea registry, calls Coolify to redeploy the CDN container - Linux job downloads the tar.gz, builds a Docker image `outlook-addin-cdn:latest`, pushes to Gitea registry, calls Coolify to redeploy the CDN container
- Posts to Mattermost channel `git-verelasim` with the install URL on success / a failure link on failure - Posts to Mattermost channel `git-verelasim` with the install URL on success / a failure link on failure
5. Lawyers' installed Add-in checks the deployment manifest at the install URL every 7 days (configured in csproj `<UpdateInterval>7</UpdateInterval>`) or whenever they click "בדוק עדכונים" in Settings → אודות. 5. Lawyers' installed Add-in checks the deployment manifest at the install URL **on every Outlook startup** (csproj has `<UpdatePeriodically>false</UpdatePeriodically>` since v1.2.9 — earlier versions waited up to 7 days). The check is a single ~6 KB HTTP GET, ~100 ms. Combined with the CI setting `/p:MinimumRequiredVersion=<tag>` per build, this means every tagged release lands on every lawyer's PC the next time they open Outlook, silently and without prompts.
**Semver policy:** PATCH for bug fixes (unilateral), MINOR for visible features (unilateral when warranted), MAJOR only with Chaim's explicit approval. **Semver policy:** PATCH for bug fixes (unilateral), MINOR for visible features (unilateral when warranted), MAJOR only with Chaim's explicit approval.
@@ -213,13 +213,29 @@ namespace MarcusLaw.OutlookAddin.Core.Services
// is sent in the "file" field as a data-URI. The previous extra // is sent in the "file" field as a data-URI. The previous extra
// "contents" alias was an undocumented legacy that strict tenants // "contents" alias was an undocumented legacy that strict tenants
// reject with 400. // reject with 400.
//
// We bind the upload to Note.attachments (parentType=Note,
// field=attachments). EspoCRM's attachment access checker now
// REJECTS a role=Attachment upload that carries neither `field`
// nor `parentType` ("No `field` and `parentType`." → 400) — the
// earlier "unbound blob" shape no longer passes. Note.attachments
// is the documented Attachment-Multiple example and a built-in
// field that carries NO "allowed file types" restriction, so every
// file type (PDF, JPEG, PNG, …) passes — unlike Document.file,
// which Marcus-Law limits to PDF/Word and which rejected images
// with 403 "Not allowed file type".
//
// We never set parentId / create a Note: the attachment stays a
// standalone blob whose id we immediately push to the network
// share. The (parentType, field) pair only satisfies the access
// checker and selects an unrestricted file-type policy.
var payload = new Dictionary<string, object?> var payload = new Dictionary<string, object?>
{ {
["name"] = fileName, ["name"] = fileName,
["type"] = mime, ["type"] = mime,
["role"] = "Attachment", ["role"] = "Attachment",
["relatedType"] = "Document", ["parentType"] = "Note",
["field"] = "file", ["field"] = "attachments",
["file"] = dataUri ["file"] = dataUri
}; };
@@ -1,9 +1,10 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog" <Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="כתוב מייל מתיק" Title="כתוב מייל מתיק"
Height="480" Width="600" Height="520" Width="680"
MinHeight="320" MinWidth="500" MinHeight="360" MinWidth="540"
FlowDirection="RightToLeft" FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
ShowInTaskbar="False" ShowInTaskbar="False"
@@ -11,6 +12,7 @@
Background="#F8FAFC" Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial"> FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources> <Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" /> <SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" /> <SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" /> <SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -134,24 +136,30 @@
</Border> </Border>
<Border Grid.Row="1" Style="{StaticResource Card}"> <Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}" <DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}" SelectedItem="{Binding SelectedCase}"
BorderThickness="0" AutoGenerateColumns="False"
Background="Transparent" HeadersVisibility="Column"
ItemContainerStyle="{StaticResource ResultItem}" GridLinesVisibility="Horizontal"
Padding="4"> HorizontalGridLinesBrush="#E2E8F0"
<ListBox.ItemTemplate> RowBackground="White"
<DataTemplate> AlternatingRowBackground="#F8FAFC"
<StackPanel> BorderThickness="0"
<TextBlock FontSize="13"> CanUserAddRows="False"
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " /> CanUserDeleteRows="False"
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" /> CanUserResizeRows="False"
</TextBlock> CanUserReorderColumns="False"
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" /> SelectionMode="Single"
</StackPanel> SelectionUnit="FullRow"
</DataTemplate> IsReadOnly="True"
</ListBox.ItemTemplate> RowHeight="30">
</ListBox> <DataGrid.Columns>
<DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
<DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
<DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
</DataGrid.Columns>
</DataGrid>
</Border> </Border>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" <TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
Cases.Clear(); Cases.Clear();
int kept = 0; var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List) foreach (var hit in search.List)
{ {
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue; if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
Cases.Add(new CaseEntity caseHits.Add(hit);
{
Id = hit.Id,
Name = hit.Name,
});
kept++;
} }
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)" if (caseHits.Count == 0)
: $"נמצאו {kept} תיקים"; {
StatusMessage = "אין תוצאות מסוג תיק (Case)";
return;
}
// GlobalSearch only returns id+name. Hydrate each hit with
// number+status (and the Hebrew case name when present) so the
// picker shows the same columns the file-attachments picker
// does.
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
foreach (var hit in caseHits)
{
detailTasks.Add(LoadCaseRowAsync(hit, ct));
}
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
foreach (var row in details)
{
if (row != null) Cases.Add(row);
}
StatusMessage = $"נמצאו {Cases.Count} תיקים";
} }
catch (OperationCanceledException) { /* superseded */ } catch (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException) catch (EspoCrmAuthorizationException)
@@ -101,6 +118,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
} }
} }
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
{
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
if (string.IsNullOrEmpty(hit.Id)) return row;
try
{
var detail = await _client.GetCaseAsync(
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
row.Number = detail.Number;
row.Status = detail.Status;
row.CourtCaseNumber = detail.CourtCaseNumber;
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.Warning(ex, "ComposeFromCase: hydrating case {CaseId} failed", hit.Id);
}
return row;
}
[RelayCommand(CanExecute = nameof(CanSubmit))] [RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit() private void Submit()
{ {
@@ -107,6 +107,38 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
} }
} }
/// <summary>
/// Seeds the case list with the cases already resolved for the mail's
/// sender (from the sidebar match) so the user doesn't have to search
/// for a client we've already identified. When exactly one case is
/// known we auto-select it (which kicks the subfolder/folder-path
/// resolve); with several we just show them and let the user pick.
/// The user can still type in the search box to override — that
/// repopulates <see cref="Cases"/> from a fresh server search.
/// </summary>
public void PreselectCases(IReadOnlyList<CaseEntity> cases)
{
if (cases == null || cases.Count == 0) return;
Cases.Clear();
foreach (var c in cases)
{
if (c == null || string.IsNullOrEmpty(c.Id)) continue;
Cases.Add(c);
}
if (Cases.Count == 0) return;
if (Cases.Count == 1)
{
SelectedCase = Cases[0];
StatusMessage = "התיק זוהה אוטומטית מהשולח";
}
else
{
StatusMessage = $"{Cases.Count} תיקים מקושרים לשולח — בחר/י תיק";
}
}
private void ResetSubfolders() private void ResetSubfolders()
{ {
Subfolders.Clear(); Subfolders.Clear();
+3
View File
@@ -257,6 +257,9 @@
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml"> <EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName> <LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Ribbon\ReadMailRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.ReadMailRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Images\klear-file.png"> <EmbeddedResource Include="Images\klear-file.png">
<LogicalName>OutlookAddin.Images.klear-file.png</LogicalName> <LogicalName>OutlookAddin.Images.klear-file.png</LogicalName>
</EmbeddedResource> </EmbeddedResource>
+2 -2
View File
@@ -33,6 +33,6 @@ using System.Security;
// You can specify all the values or you can default the Build and Revision Numbers // You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below: // by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")] // [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.2.10.0")] [assembly: AssemblyVersion("1.2.14.0")]
[assembly: AssemblyFileVersion("1.2.10.0")] [assembly: AssemblyFileVersion("1.2.14.0")]
+49
View File
@@ -19,6 +19,7 @@ namespace OutlookAddin.Ribbon
{ {
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml"; private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml"; private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
private const string ReadMailResource = "OutlookAddin.Ribbon.ReadMailRibbon.xml";
// Every button whose `getEnabled` depends on the current Explorer // Every button whose `getEnabled` depends on the current Explorer
// selection must be re-evaluated on every SelectionChange — Office // selection must be re-evaluated on every SelectionChange — Office
@@ -40,6 +41,8 @@ namespace OutlookAddin.Ribbon
return LoadResource(ExplorerResource); return LoadResource(ExplorerResource);
case "Microsoft.Outlook.Mail.Compose": case "Microsoft.Outlook.Mail.Compose":
return LoadResource(InspectorResource); return LoadResource(InspectorResource);
case "Microsoft.Outlook.Mail.Read":
return LoadResource(ReadMailResource);
default: default:
return string.Empty; return string.Empty;
} }
@@ -77,6 +80,51 @@ namespace OutlookAddin.Ribbon
_inspectorRibbon = ribbon; _inspectorRibbon = ribbon;
} }
// The read-mail inspector ribbon loads fresh for each opened message,
// so its getEnabled callback re-runs per window — no cached IRibbonUI
// or invalidation is needed here.
public void OnReadMailRibbonLoad(IRibbonUI ribbon)
{
}
/// <summary>
/// Enabled when the message open in this read inspector has at least
/// one attachment. Mirrors <see cref="OnGetSaveAttachmentsEnabled"/>
/// but reads the item from the Inspector context rather than the
/// Explorer selection.
/// </summary>
public bool OnGetSaveAttachmentsInspectorEnabled(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem is not Outlook.MailItem mail) return false;
var attachments = mail.Attachments;
if (attachments == null) return false;
return attachments.Count > 0;
}
catch
{
return false;
}
}
public void OnSaveAttachmentsFromInspector(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem is Outlook.MailItem mail)
{
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
}
}
catch (Exception ex)
{
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachmentsFromInspector failed");
}
}
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control) public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
{ {
try try
@@ -206,6 +254,7 @@ namespace OutlookAddin.Ribbon
{ {
case "FileToEspoCrm": fileName = "klear-file.png"; break; case "FileToEspoCrm": fileName = "klear-file.png"; break;
case "SaveAttachments": fileName = "klear-attach.png"; break; case "SaveAttachments": fileName = "klear-attach.png"; break;
case "SaveAttachmentsRead": fileName = "klear-attach.png"; break;
case "ShowSidebar": fileName = "klear-sidebar.png"; break; case "ShowSidebar": fileName = "klear-sidebar.png"; break;
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break; case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
case "ComposeFromCase": fileName = "klear-compose.png"; break; case "ComposeFromCase": fileName = "klear-compose.png"; break;
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="OnReadMailRibbonLoad">
<ribbon>
<tabs>
<tab idMso="TabReadMessage">
<group id="MarcusLawReadGroup" label="Klear">
<button id="SaveAttachmentsRead"
label="שמור קבצים"
screentip="שמור את הקבצים המצורפים בלבד"
supertip="מעלה את הקבצים המצורפים של המייל הזה ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
size="large"
getImage="OnGetImage"
onAction="OnSaveAttachmentsFromInspector"
getEnabled="OnGetSaveAttachmentsInspectorEnabled" />
</group>
</tab>
</tabs>
</ribbon>
</customUI>
+31
View File
@@ -485,6 +485,13 @@ namespace OutlookAddin.Services
// Subfolders are populated lazily once the user picks a case; // Subfolders are populated lazily once the user picks a case;
// the default static set is already pre-loaded. // the default static set is already pre-loaded.
// If the sender already maps to a known client, seed the case
// list so the user sees the identified case pre-selected instead
// of having to search. Uses the same matching lookup the sidebar
// does (cached for 10 min), so this is normally instant. Any
// failure here is non-fatal — the dialog still opens empty.
await TryPreselectSenderCaseAsync(vm, envelope.From).ConfigureAwait(true);
var dialog = new SaveAttachmentsDialog(vm); var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog); AttachOwnerToOutlook(dialog);
var ok = dialog.ShowDialog(); var ok = dialog.ShowDialog();
@@ -541,6 +548,30 @@ namespace OutlookAddin.Services
} }
} }
/// <summary>
/// Looks up the mail sender against the CRM (reusing the sidebar's
/// cached match) and, when it resolves to one or more linked cases,
/// seeds the save-attachments dialog so the already-identified client
/// case shows up pre-selected. Best-effort: swallows any error.
/// </summary>
private async Task TryPreselectSenderCaseAsync(SaveAttachmentsViewModel vm, string? senderEmail)
{
if (MatchingService == null || string.IsNullOrWhiteSpace(senderEmail)) return;
try
{
var match = await MatchingService.LookupAsync(senderEmail!).ConfigureAwait(true);
// Ambiguous (same email on several contacts) → don't guess; let
// the user search. A clean match with linked cases → seed them.
if (match == null || match.IsAmbiguous) return;
if (match.RecentCases == null || match.RecentCases.Count == 0) return;
vm.PreselectCases(match.RecentCases);
}
catch (Exception ex)
{
Logger.Warning(ex, "Sender-case preselect for save-attachments failed (non-fatal)");
}
}
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath) private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
{ {
string message; string message;
+60 -19
View File
@@ -42,21 +42,7 @@ namespace OutlookAddin.Services
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId)); if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true); var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
ContactEntity? primaryContact = null; var recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
if (caseEntity.ContactsIds != null && caseEntity.ContactsIds.Count > 0)
{
try
{
primaryContact = await _client.GetContactAsync(
caseEntity.ContactsIds[0],
"id,name,emailAddress",
default).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load primary contact for case {CaseId}", caseId);
}
}
var guid = Guid.NewGuid().ToString("N"); var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id); StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
try try
{ {
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress)) if (!string.IsNullOrWhiteSpace(recipientEmail))
{ {
var existingTo = mail.To ?? string.Empty; var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!)) if (!existingTo.Contains(recipientEmail!))
{ {
mail.To = string.IsNullOrEmpty(existingTo) mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress ? recipientEmail
: existingTo + "; " + primaryContact.EmailAddress; : existingTo + "; " + recipientEmail;
} }
} }
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id)); new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id));
} }
/// <summary>
/// Find a usable recipient email for the case. EspoCRM's Case.contactsIds
/// is a many-to-many bag (primary + co-counsel + witnesses + …) and the
/// first entry isn't guaranteed to be the actual client — and even when
/// it is, the contact may have no emailAddress stored. So walk the list
/// and return the first non-empty address, then fall back to the
/// linked Account's email.
/// </summary>
private async Task<string?> ResolveRecipientEmailAsync(CaseEntity caseEntity, string caseId)
{
if (caseEntity.ContactsIds != null)
{
foreach (var contactId in caseEntity.ContactsIds)
{
if (string.IsNullOrWhiteSpace(contactId)) continue;
try
{
var contact = await _client.GetContactAsync(
contactId,
"id,name,emailAddress,emailAddressData",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(contact?.EmailAddress))
{
return contact!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load contact {ContactId} for case {CaseId}", contactId, caseId);
}
}
}
if (!string.IsNullOrWhiteSpace(caseEntity.AccountId))
{
try
{
var account = await _client.GetAccountAsync(
caseEntity.AccountId!,
"id,name,emailAddress",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(account?.EmailAddress))
{
return account!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load account {AccountId} for case {CaseId}", caseEntity.AccountId, caseId);
}
}
return null;
}
public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty); public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty); public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty); public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);