Compare commits

...

7 Commits

Author SHA1 Message Date
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
PointStar 32f6facb67 publish: 1.2.10 — verify on-every-startup auto-update from v1.2.9
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>
2026-05-24 20:53:54 +03:00
PointStar 2228353c48 fix(updater): check for updates on every Outlook startup, not every 7 days
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>
2026-05-24 20:50:31 +03:00
13 changed files with 305 additions and 75 deletions
+13 -7
View File
@@ -258,15 +258,21 @@ jobs:
& msbuild @msbuildArgs
if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" }
# Sanity check: did the VSTO publish target produce a .vsto file?
$vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" }
# Sanity check + rename to canonical OutlookAddin.vsto so the URL
# matches PROVIDER_URL (hardcoded to .../OutlookAddin.vsto). Restrict
# 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)"
# 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') {
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 ==="
+5 -10
View File
@@ -315,16 +315,11 @@ git push origin v1.0.1
ה-CI ירוץ אוטומטית כמו בשחרור הראשון.
**אצל הלקוחות:**
- ClickOnce בודק עדכון אוטומטית **כל 7 ימים** (הגדרה ב-csproj: `UpdateInterval=7`).
- כדי **להאיץ** עדכון אצל לקוח מסויים:
1. סגור את Outlook לגמרי.
2. פתח את Outlook שוב.
3. אם עברו לפחות 7 ימים מבדיקת ה-update הקודמת — יבדוק עכשיו ויעדכן.
4. אם לא — אפשר לנקות את ה-ClickOnce cache:
```powershell
rundll32.exe dfshim.dll,CleanOnlineAppCache
```
ואז לפתוח שוב את `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`.
- ה־VSTO runtime בודק את ה-`.vsto` manifest **בכל פתיחה של Outlook** (משוחזר מ-`<UpdatePeriodically>false</UpdatePeriodically>` ב-csproj החל מ-v1.2.9; קודם זה היה פעם ב-7 ימים). הבדיקה היא GET של ~6KB, ~100ms — בלתי מורגש בזמן הטעינה.
- ה-CI מציב `/p:MinimumRequiredVersion=<tag>` בכל build, אז מיד כשהבדיקה מזהה גרסה חדשה — ההתקנה נכפית בלי דיאלוג.
- **תרגום מעשי**: 30 שניות אחרי `git push --tags` ה-CI מסיים → תוך 2-3 דקות החבילה נדחפת ל-CDN → בכניסה הבאה של כל אחד מהלקוחות ל-Outlook הוא רץ על הגרסה החדשה. **בלי שום פעולה ידנית מצידם**.
- **הכפתור "בדוק עדכונים" בהגדרות** רק מודיע ("זמינה גרסה X — סגור ופתח Outlook"). הוא לא יכול להתקין מתוך תהליך Outlook רץ — `VSTOInstaller.exe` לא יכול לדרוס DLLs נעולים, וה-ApplicationDeployment API נכשל ב-`TrustNotGrantedException` בהקשר VSTO. ההתקנה תמיד קורית רק דרך ה-runtime בעלייה של Outlook. [פירוט בזיכרון: feedback_vsto_updater_api.md]
- **תקלה נדירה — לקוח שהותקן ידנית עם 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 עובד.
---
+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`)
- 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
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.
@@ -213,13 +213,22 @@ namespace MarcusLaw.OutlookAddin.Core.Services
// is sent in the "file" field as a data-URI. The previous extra
// "contents" alias was an undocumented legacy that strict tenants
// reject with 400.
//
// We deliberately DO NOT bind the upload to Document.file
// (relatedType=Document / field=file). EspoCRM only enforces the
// per-field "allowed file types" list when a field is supplied, and
// Marcus-Law's Document.file field is configured to accept document
// types only (PDF/Word) — images (JPEG/PNG) were rejected with
// 403 "Not allowed file type". This flow never creates a Document;
// the attachment is a standalone blob we immediately push to the
// network share, so an unbound role=Attachment upload is the
// correct shape and lets every file type through. (Server-side fix:
// widen Document.file's accepted types in the Entity Manager.)
var payload = new Dictionary<string, object?>
{
["name"] = fileName,
["type"] = mime,
["role"] = "Attachment",
["relatedType"] = "Document",
["field"] = "file",
["file"] = dataUri
};
@@ -1,9 +1,10 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="כתוב מייל מתיק"
Height="480" Width="600"
MinHeight="320" MinWidth="500"
Height="520" Width="680"
MinHeight="360" MinWidth="540"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
@@ -11,6 +12,7 @@
Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -134,24 +136,30 @@
</Border>
<Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
BorderThickness="0"
Background="Transparent"
ItemContainerStyle="{StaticResource ResultItem}"
Padding="4">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock FontSize="13">
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#E2E8F0"
RowBackground="White"
AlternatingRowBackground="#F8FAFC"
BorderThickness="0"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserResizeRows="False"
CanUserReorderColumns="False"
SelectionMode="Single"
SelectionUnit="FullRow"
IsReadOnly="True"
RowHeight="30">
<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>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested();
Cases.Clear();
int kept = 0;
var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List)
{
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
Cases.Add(new CaseEntity
{
Id = hit.Id,
Name = hit.Name,
});
kept++;
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
caseHits.Add(hit);
}
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)"
: $"נמצאו {kept} תיקים";
if (caseHits.Count == 0)
{
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 (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))]
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()
{
Subfolders.Clear();
+5 -3
View File
@@ -36,11 +36,10 @@
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl>
<InstallUrl>https://gitea.dev.marcus-law.co.il/espocrm-extensions/OutlookAddin/raw/branch/main/Publish/</InstallUrl>
<TargetCulture>en</TargetCulture>
<ApplicationVersion>1.2.8.0</ApplicationVersion>
<ApplicationVersion>1.2.10.0</ApplicationVersion>
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
<UpdateEnabled>true</UpdateEnabled>
<UpdateInterval>7</UpdateInterval>
<UpdateIntervalUnits>days</UpdateIntervalUnits>
<UpdatePeriodically>false</UpdatePeriodically>
<ProductName>MarcusLaw.OutlookAddin</ProductName>
<PublisherName />
<SupportUrl />
@@ -258,6 +257,9 @@
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Ribbon\ReadMailRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.ReadMailRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Images\klear-file.png">
<LogicalName>OutlookAddin.Images.klear-file.png</LogicalName>
</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
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.2.8.0")]
[assembly: AssemblyFileVersion("1.2.8.0")]
[assembly: AssemblyVersion("1.2.13.0")]
[assembly: AssemblyFileVersion("1.2.13.0")]
+49
View File
@@ -19,6 +19,7 @@ namespace OutlookAddin.Ribbon
{
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.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
// selection must be re-evaluated on every SelectionChange — Office
@@ -40,6 +41,8 @@ namespace OutlookAddin.Ribbon
return LoadResource(ExplorerResource);
case "Microsoft.Outlook.Mail.Compose":
return LoadResource(InspectorResource);
case "Microsoft.Outlook.Mail.Read":
return LoadResource(ReadMailResource);
default:
return string.Empty;
}
@@ -77,6 +80,51 @@ namespace OutlookAddin.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)
{
try
@@ -206,6 +254,7 @@ namespace OutlookAddin.Ribbon
{
case "FileToEspoCrm": fileName = "klear-file.png"; break;
case "SaveAttachments": fileName = "klear-attach.png"; break;
case "SaveAttachmentsRead": fileName = "klear-attach.png"; break;
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
case "OpenEspoCrmSettings": fileName = "klear-settings.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;
// 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);
AttachOwnerToOutlook(dialog);
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)
{
string message;
+60 -19
View File
@@ -42,21 +42,7 @@ namespace OutlookAddin.Services
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);
ContactEntity? primaryContact = null;
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 recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
try
{
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress))
if (!string.IsNullOrWhiteSpace(recipientEmail))
{
var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!))
if (!existingTo.Contains(recipientEmail!))
{
mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress
: existingTo + "; " + primaryContact.EmailAddress;
? recipientEmail
: existingTo + "; " + recipientEmail;
}
}
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
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? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);