diff --git a/src/OutlookAddin.Core/Models/AttachmentEntity.cs b/src/OutlookAddin.Core/Models/AttachmentEntity.cs
new file mode 100644
index 0000000..36807a5
--- /dev/null
+++ b/src/OutlookAddin.Core/Models/AttachmentEntity.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace MarcusLaw.OutlookAddin.Core.Models
+{
+ ///
+ /// Response from POST /api/v1/Attachment — the server returns at least
+ /// the new attachment's id, which we then thread into a Document create.
+ ///
+ public sealed class AttachmentEntity
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+ }
+}
diff --git a/src/OutlookAddin.Core/Models/DocumentEntity.cs b/src/OutlookAddin.Core/Models/DocumentEntity.cs
new file mode 100644
index 0000000..fbf85f9
--- /dev/null
+++ b/src/OutlookAddin.Core/Models/DocumentEntity.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+
+namespace MarcusLaw.OutlookAddin.Core.Models
+{
+ public sealed class DocumentEntity
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("fileId")]
+ public string? FileId { get; set; }
+
+ [JsonPropertyName("folderId")]
+ public string? FolderId { get; set; }
+
+ [JsonPropertyName("parentType")]
+ public string? ParentType { get; set; }
+
+ [JsonPropertyName("parentId")]
+ public string? ParentId { get; set; }
+ }
+}
diff --git a/src/OutlookAddin.Core/Models/DocumentFolder.cs b/src/OutlookAddin.Core/Models/DocumentFolder.cs
new file mode 100644
index 0000000..c06a509
--- /dev/null
+++ b/src/OutlookAddin.Core/Models/DocumentFolder.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace MarcusLaw.OutlookAddin.Core.Models
+{
+ ///
+ /// A row from /api/v1/DocumentFolder — used to populate the folder
+ /// dropdown in the "Save attachments" dialog. EspoCRM document folders
+ /// are organizational labels independent of the document's parent link.
+ ///
+ public sealed class DocumentFolder
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("parentId")]
+ public string? ParentId { get; set; }
+
+ [JsonPropertyName("order")]
+ public int? Order { get; set; }
+ }
+}
diff --git a/src/OutlookAddin.Core/Services/AttachmentSaveService.cs b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs
new file mode 100644
index 0000000..7648389
--- /dev/null
+++ b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MarcusLaw.OutlookAddin.Core.Models;
+using Serilog;
+
+namespace MarcusLaw.OutlookAddin.Core.Services
+{
+ public sealed class AttachmentSaveService : IAttachmentSaveService
+ {
+ private readonly IEspoCrmClient _client;
+ private readonly ILogger _logger;
+
+ public AttachmentSaveService(IEspoCrmClient client, ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task SaveAsync(
+ IReadOnlyList attachments,
+ FilingTarget target,
+ string? folderId,
+ CancellationToken cancellationToken = default)
+ {
+ if (attachments == null) throw new ArgumentNullException(nameof(attachments));
+ if (target == null) throw new ArgumentNullException(nameof(target));
+
+ var summary = new AttachmentSaveSummary();
+ if (attachments.Count == 0) return summary;
+
+ foreach (var att in attachments)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (summary.AuthRequired)
+ {
+ summary.Items.Add(new AttachmentSaveItemResult
+ {
+ FileName = att.Name,
+ Outcome = AttachmentSaveOutcome.AuthRequired,
+ ErrorMessage = "Skipped — earlier upload rejected by EspoCRM"
+ });
+ continue;
+ }
+
+ try
+ {
+ var uploaded = await _client.UploadAttachmentAsync(
+ att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
+
+ var doc = await _client.CreateDocumentAsync(
+ att.Name, uploaded.Id, target.ParentType, target.ParentId, folderId, cancellationToken).ConfigureAwait(false);
+
+ _logger.Information(
+ "Saved attachment {Name} → Document/{DocId} (parent={ParentType}/{ParentId}, folder={FolderId})",
+ att.Name, doc.Id, target.ParentType, target.ParentId, folderId ?? "(none)");
+
+ summary.Items.Add(new AttachmentSaveItemResult
+ {
+ FileName = att.Name,
+ Outcome = AttachmentSaveOutcome.Saved,
+ DocumentId = doc.Id
+ });
+ }
+ catch (EspoCrmAuthorizationException ex)
+ {
+ _logger.Warning(ex, "Attachment save aborted: EspoCRM rejected credentials");
+ summary.AuthRequired = true;
+ summary.Items.Add(new AttachmentSaveItemResult
+ {
+ FileName = att.Name,
+ Outcome = AttachmentSaveOutcome.AuthRequired,
+ ErrorMessage = ex.Message
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "Attachment save failed for {Name}", att.Name);
+ summary.Items.Add(new AttachmentSaveItemResult
+ {
+ FileName = att.Name,
+ Outcome = AttachmentSaveOutcome.Failed,
+ ErrorMessage = ex.Message
+ });
+ }
+ }
+
+ return summary;
+ }
+ }
+}
diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs
index 96252ed..e2fc49f 100644
--- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs
+++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs
@@ -202,6 +202,86 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public Task TestConnectionAsync(CancellationToken cancellationToken = default)
=> GetJsonAsync("App/user", cancellationToken);
+ public async Task UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName));
+ if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content));
+
+ // EspoCRM accepts either raw base64 in `contents` or a data-URI with the
+ // mime prefix. The data-URI form is universally supported across versions.
+ var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
+ var dataUri = "data:" + mime + ";base64," + base64Content;
+
+ var payload = new Dictionary
+ {
+ ["name"] = fileName,
+ ["type"] = mime,
+ ["contents"] = dataUri,
+ ["role"] = "Attachment",
+ ["relatedType"] = "Document",
+ ["field"] = "file"
+ };
+
+ var json = JsonSerializer.Serialize(payload, JsonOptions);
+ var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await EnsureSuccessAsync(response).ConfigureAwait(false);
+ var body = await ReadBodyAsync(response).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, JsonOptions)
+ ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM");
+ }
+ finally
+ {
+ response.Dispose();
+ }
+ }
+
+ public async Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
+ if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId));
+
+ var payload = new Dictionary
+ {
+ ["name"] = name,
+ ["fileId"] = attachmentFileId,
+ ["status"] = "Active"
+ };
+ if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType;
+ if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
+ if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId;
+
+ var json = JsonSerializer.Serialize(payload, JsonOptions);
+ var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await EnsureSuccessAsync(response).ConfigureAwait(false);
+ var body = await ReadBodyAsync(response).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, JsonOptions)
+ ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM");
+ }
+ finally
+ {
+ response.Dispose();
+ }
+ }
+
+ public async Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await GetJsonAsync>(
+ "DocumentFolder?orderBy=name&order=asc&maxSize=200",
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list");
+ return new EspoListResponse();
+ }
+ }
+
public async Task> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
diff --git a/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs
new file mode 100644
index 0000000..dc214b8
--- /dev/null
+++ b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MarcusLaw.OutlookAddin.Core.Models;
+
+namespace MarcusLaw.OutlookAddin.Core.Services
+{
+ public interface IAttachmentSaveService
+ {
+ ///
+ /// Uploads each attachment to EspoCRM and creates a Document linked
+ /// to the given target (typically a Case). Folder is optional —
+ /// pass null to leave the document at the root of the Documents
+ /// module.
+ ///
+ Task SaveAsync(
+ IReadOnlyList attachments,
+ FilingTarget target,
+ string? folderId,
+ CancellationToken cancellationToken = default);
+ }
+
+ public sealed class AttachmentSaveSummary
+ {
+ public List Items { get; set; } = new List();
+
+ public bool AuthRequired { get; set; }
+
+ public int SavedCount
+ {
+ get
+ {
+ var n = 0;
+ foreach (var it in Items)
+ {
+ if (it.Outcome == AttachmentSaveOutcome.Saved) n++;
+ }
+ return n;
+ }
+ }
+
+ public int FailedCount
+ {
+ get
+ {
+ var n = 0;
+ foreach (var it in Items)
+ {
+ if (it.Outcome == AttachmentSaveOutcome.Failed) n++;
+ }
+ return n;
+ }
+ }
+ }
+
+ public sealed class AttachmentSaveItemResult
+ {
+ public string FileName { get; set; } = string.Empty;
+
+ public AttachmentSaveOutcome Outcome { get; set; }
+
+ public string? DocumentId { get; set; }
+
+ public string? ErrorMessage { get; set; }
+ }
+
+ public enum AttachmentSaveOutcome
+ {
+ Saved,
+ Failed,
+ AuthRequired
+ }
+}
diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs
index 6036296..9d2177e 100644
--- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs
+++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs
@@ -15,5 +15,9 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Task CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
Task> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
Task TestConnectionAsync(CancellationToken cancellationToken = default);
+
+ Task UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default);
+ Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
+ Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
}
}
diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml
new file mode 100644
index 0000000..c51296c
--- /dev/null
+++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs
new file mode 100644
index 0000000..9c074ed
--- /dev/null
+++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Windows;
+using MarcusLaw.OutlookAddin.UI.ViewModels;
+
+namespace MarcusLaw.OutlookAddin.UI.Dialogs
+{
+ public partial class SaveAttachmentsDialog : Window
+ {
+ private SaveAttachmentsViewModel? _viewModel;
+
+ public SaveAttachmentsDialog()
+ {
+ InitializeComponent();
+ Loaded += (_, __) => SearchBox.Focus();
+ }
+
+ public SaveAttachmentsDialog(SaveAttachmentsViewModel viewModel) : this()
+ {
+ AttachViewModel(viewModel);
+ }
+
+ public void AttachViewModel(SaveAttachmentsViewModel viewModel)
+ {
+ if (_viewModel != null) _viewModel.RequestClose -= OnRequestClose;
+ _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
+ DataContext = _viewModel;
+ _viewModel.RequestClose += OnRequestClose;
+ }
+
+ private void OnRequestClose(object? sender, EventArgs e)
+ {
+ DialogResult = _viewModel?.DialogResult;
+ Close();
+ }
+ }
+}
diff --git a/src/OutlookAddin.UI/ViewModels/AttachmentRowViewModel.cs b/src/OutlookAddin.UI/ViewModels/AttachmentRowViewModel.cs
new file mode 100644
index 0000000..f510a0a
--- /dev/null
+++ b/src/OutlookAddin.UI/ViewModels/AttachmentRowViewModel.cs
@@ -0,0 +1,41 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using MarcusLaw.OutlookAddin.Core.Models;
+
+namespace MarcusLaw.OutlookAddin.UI.ViewModels
+{
+ public sealed partial class AttachmentRowViewModel : ObservableObject
+ {
+ public EspoAttachment Data { get; }
+
+ [ObservableProperty]
+ private bool isSelected = true;
+
+ public string FileName => Data.Name;
+
+ public string SizeDisplay { get; }
+
+ public AttachmentRowViewModel(EspoAttachment data)
+ {
+ Data = data;
+ SizeDisplay = ComputeSize(data.ContentBase64);
+ }
+
+ private static string ComputeSize(string base64)
+ {
+ if (string.IsNullOrEmpty(base64)) return string.Empty;
+ // Base64 expands binary 4-for-3, ignoring padding chars.
+ var padding = 0;
+ if (base64.Length > 0 && base64[base64.Length - 1] == '=') padding++;
+ if (base64.Length > 1 && base64[base64.Length - 2] == '=') padding++;
+ var bytes = (long)((base64.Length * 3) / 4) - padding;
+ return FormatBytes(bytes);
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("F1") + " KB";
+ return (bytes / (1024.0 * 1024.0)).ToString("F1") + " MB";
+ }
+ }
+}
diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
new file mode 100644
index 0000000..d1939f3
--- /dev/null
+++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using MarcusLaw.OutlookAddin.Core.Models;
+using MarcusLaw.OutlookAddin.Core.Services;
+using Serilog;
+
+namespace MarcusLaw.OutlookAddin.UI.ViewModels
+{
+ public sealed partial class SaveAttachmentsViewModel : ObservableObject
+ {
+ private readonly IEspoCrmClient _client;
+ private readonly ILogger _logger;
+ private CancellationTokenSource? _searchCts;
+
+ [ObservableProperty]
+ private string searchText = string.Empty;
+
+ [ObservableProperty]
+ private CaseEntity? selectedCase;
+
+ [ObservableProperty]
+ private DocumentFolder? selectedFolder;
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ public ObservableCollection Cases { get; } = new ObservableCollection();
+
+ public ObservableCollection Folders { get; } = new ObservableCollection();
+
+ public ObservableCollection Attachments { get; } = new ObservableCollection();
+
+ public bool? DialogResult { get; private set; }
+
+ public IReadOnlyList SelectedAttachments
+ {
+ get
+ {
+ var list = new List();
+ foreach (var row in Attachments)
+ {
+ if (row.IsSelected) list.Add(row.Data);
+ }
+ return list;
+ }
+ }
+
+ public FilingTarget? Target
+ {
+ get
+ {
+ if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return null;
+ return new FilingTarget("Case", SelectedCase.Id, SelectedCase.Name ?? SelectedCase.Id);
+ }
+ }
+
+ public string? FolderId =>
+ SelectedFolder == null || string.IsNullOrWhiteSpace(SelectedFolder.Id)
+ ? null
+ : SelectedFolder.Id;
+
+ public event EventHandler? RequestClose;
+
+ public SaveAttachmentsViewModel(
+ IEspoCrmClient client,
+ ILogger logger,
+ IEnumerable attachments)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ // Sentinel "no folder" row at the top — Id stays null so the
+ // service skips emitting folderId in the payload.
+ Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" });
+ SelectedFolder = Folders[0];
+
+ foreach (var att in attachments)
+ {
+ Attachments.Add(new AttachmentRowViewModel(att));
+ }
+ }
+
+ public async Task LoadFoldersAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var result = await _client.ListDocumentFoldersAsync(ct).ConfigureAwait(true);
+ foreach (var folder in result.List)
+ {
+ Folders.Add(folder);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "SaveAttachmentsViewModel.LoadFoldersAsync failed");
+ }
+ }
+
+ partial void OnSearchTextChanged(string value)
+ {
+ _searchCts?.Cancel();
+ _searchCts = new CancellationTokenSource();
+ _ = SearchAsync(value, _searchCts.Token);
+ }
+
+ partial void OnSelectedCaseChanged(CaseEntity? value)
+ {
+ SubmitCommand.NotifyCanExecuteChanged();
+ }
+
+ private async Task SearchAsync(string query, CancellationToken ct)
+ {
+ try
+ {
+ await Task.Delay(250, ct).ConfigureAwait(true);
+
+ var trimmed = (query ?? string.Empty).Trim();
+ if (trimmed.Length < 2)
+ {
+ Cases.Clear();
+ StatusMessage = null;
+ return;
+ }
+
+ var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
+ ct.ThrowIfCancellationRequested();
+
+ Cases.Clear();
+ int kept = 0;
+ 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++;
+ }
+ StatusMessage = kept == 0
+ ? "אין תוצאות מסוג תיק (Case)"
+ : $"נמצאו {kept} תיקים";
+ }
+ catch (OperationCanceledException) { /* superseded */ }
+ catch (EspoCrmAuthorizationException)
+ {
+ StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "SaveAttachments search failed");
+ StatusMessage = "שגיאת חיפוש: " + ex.Message;
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanSubmit))]
+ private void Submit()
+ {
+ DialogResult = true;
+ RequestClose?.Invoke(this, EventArgs.Empty);
+ }
+
+ private bool CanSubmit()
+ {
+ if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return false;
+ foreach (var row in Attachments)
+ {
+ if (row.IsSelected) return true;
+ }
+ return false;
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ DialogResult = false;
+ RequestClose?.Invoke(this, EventArgs.Empty);
+ }
+ }
+}
diff --git a/src/OutlookAddin/Images/klear-attach.png b/src/OutlookAddin/Images/klear-attach.png
new file mode 100644
index 0000000..cced8a1
Binary files /dev/null and b/src/OutlookAddin/Images/klear-attach.png differ
diff --git a/src/OutlookAddin/OutlookAddin.csproj b/src/OutlookAddin/OutlookAddin.csproj
index a287928..228a50d 100644
--- a/src/OutlookAddin/OutlookAddin.csproj
+++ b/src/OutlookAddin/OutlookAddin.csproj
@@ -270,6 +270,9 @@
OutlookAddin.Images.klear-compose.png
+
+ OutlookAddin.Images.klear-attach.png
+
Code
diff --git a/src/OutlookAddin/Ribbon/ExplorerRibbon.cs b/src/OutlookAddin/Ribbon/ExplorerRibbon.cs
index 2b5b1a2..261b990 100644
--- a/src/OutlookAddin/Ribbon/ExplorerRibbon.cs
+++ b/src/OutlookAddin/Ribbon/ExplorerRibbon.cs
@@ -92,6 +92,43 @@ namespace OutlookAddin.Ribbon
Globals.ThisAddIn.AddInHost.ShowSidebar();
}
+ public bool OnGetSaveAttachmentsEnabled(IRibbonControl control)
+ {
+ // Enabled only when EXACTLY one MailItem is selected AND it has
+ // at least one attachment. Doing multi-mail save would conflict
+ // with the per-file checkbox UX in the dialog.
+ try
+ {
+ var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
+ if (selection == null || selection.Count != 1) return false;
+ if (selection[1] 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 OnSaveAttachments(IRibbonControl control)
+ {
+ try
+ {
+ var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
+ if (selection == null || selection.Count != 1) return;
+ if (selection[1] is Outlook.MailItem mail)
+ {
+ _ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
+ }
+ }
+ catch (Exception ex)
+ {
+ Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachments failed");
+ }
+ }
+
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
{
// Show only on outgoing (compose) mail inspectors.
@@ -150,6 +187,7 @@ namespace OutlookAddin.Ribbon
switch (control.Id)
{
case "FileToEspoCrm": fileName = "klear-file.png"; break;
+ case "SaveAttachments": 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;
diff --git a/src/OutlookAddin/Ribbon/ExplorerRibbon.xml b/src/OutlookAddin/Ribbon/ExplorerRibbon.xml
index 59dc97c..0cfa1c9 100644
--- a/src/OutlookAddin/Ribbon/ExplorerRibbon.xml
+++ b/src/OutlookAddin/Ribbon/ExplorerRibbon.xml
@@ -12,6 +12,14 @@
getImage="OnGetImage"
onAction="OnFileToEspoCrm"
getEnabled="OnGetFileToEspoCrmEnabled" />
+