diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json
index b40fac9..160f915 100644
--- a/.taskmaster/tasks/tasks.json
+++ b/.taskmaster/tasks/tasks.json
@@ -35,8 +35,9 @@
"dependencies": [
2
],
- "status": "pending",
- "subtasks": []
+ "status": "in-review",
+ "subtasks": [],
+ "implementationNotes": "Implemented 2026-05-11 (commits e527aa8, 14ed1f6). Deltas from spec: (1) Used MessageBox for summary instead of UWP toast — simpler, can upgrade later. (2) Tests written under OutlookAddin.Tests/Services/ rather than /Core/. (3) Dialog placed in OutlookAddin.UI/Dialogs/ rather than /Views/. (4) Retry queue keeps items on disk after max attempts (3) instead of moving to failed/ — operator can inspect in retry-queue/ folder. (5) MailItem extraction lives in OutlookAddin/Services/MailItemExtractor.cs (host project, since Outlook interop). Build verified for Core/UI/Tests (38 tests green); host project requires VS Installer Repair to compile (VSTO targets still missing on dev machine). Outstanding: first-run settings dialog for credential entry — host shows \"תוסף עדיין לא מוגדר\" if creds.dat absent."
},
{
"id": 4,
diff --git a/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml
new file mode 100644
index 0000000..6fc1d34
--- /dev/null
+++ b/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml.cs b/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml.cs
new file mode 100644
index 0000000..4886d9b
--- /dev/null
+++ b/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Windows;
+using MarcusLaw.OutlookAddin.UI.ViewModels;
+
+namespace MarcusLaw.OutlookAddin.UI.Dialogs
+{
+ public partial class SettingsDialog : Window
+ {
+ private SettingsViewModel? _viewModel;
+
+ public SettingsDialog()
+ {
+ InitializeComponent();
+ }
+
+ public SettingsDialog(SettingsViewModel viewModel) : this()
+ {
+ AttachViewModel(viewModel);
+ }
+
+ public void AttachViewModel(SettingsViewModel viewModel)
+ {
+ if (_viewModel != null)
+ {
+ _viewModel.RequestClose -= OnRequestClose;
+ _viewModel.ApiKeyReader = null;
+ }
+ _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
+ DataContext = _viewModel;
+ _viewModel.RequestClose += OnRequestClose;
+ _viewModel.ApiKeyReader = () => ApiKeyBox.Password;
+ if (!string.IsNullOrEmpty(_viewModel.InitialApiKey))
+ {
+ ApiKeyBox.Password = _viewModel.InitialApiKey;
+ }
+ }
+
+ private void OnRequestClose(object? sender, EventArgs e)
+ {
+ DialogResult = _viewModel?.DialogResult;
+ Close();
+ }
+ }
+}
diff --git a/src/OutlookAddin.UI/OutlookAddin.UI.csproj b/src/OutlookAddin.UI/OutlookAddin.UI.csproj
index b881a3a..7dc2aaa 100644
--- a/src/OutlookAddin.UI/OutlookAddin.UI.csproj
+++ b/src/OutlookAddin.UI/OutlookAddin.UI.csproj
@@ -16,6 +16,10 @@
+
+
+
+
diff --git a/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..4821112
--- /dev/null
+++ b/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using System.Windows.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using MarcusLaw.OutlookAddin.Core.Configuration;
+using MarcusLaw.OutlookAddin.Core.Security;
+using MarcusLaw.OutlookAddin.Core.Services;
+using Serilog;
+
+namespace MarcusLaw.OutlookAddin.UI.ViewModels
+{
+ public sealed partial class SettingsViewModel : ObservableObject
+ {
+ private static readonly Brush NeutralBrush = Brushes.Gray;
+ private static readonly Brush ErrorBrush = Brushes.Firebrick;
+ private static readonly Brush SuccessBrush = Brushes.SeaGreen;
+
+ private readonly AppSettings _settings;
+ private readonly SettingsManager _settingsManager;
+ private readonly DpapiCredentialStore _credentialStore;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ private string espoCrmUrl = string.Empty;
+
+ [ObservableProperty]
+ private string username = string.Empty;
+
+ [ObservableProperty]
+ private string locale = "he-IL";
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ [ObservableProperty]
+ private Brush statusBrush = NeutralBrush;
+
+ public string InitialApiKey { get; }
+
+ public string VersionLine { get; }
+
+ public Func? ApiKeyReader { get; set; }
+
+ public bool? DialogResult { get; private set; }
+
+ public event EventHandler? RequestClose;
+
+ public SettingsViewModel(
+ AppSettings settings,
+ SettingsManager settingsManager,
+ DpapiCredentialStore credentialStore,
+ ILogger logger)
+ {
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ _settingsManager = settingsManager ?? throw new ArgumentNullException(nameof(settingsManager));
+ _credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ EspoCrmUrl = settings.EspoCrmUrl;
+ Locale = string.IsNullOrWhiteSpace(settings.Locale) ? "he-IL" : settings.Locale;
+
+ var creds = _credentialStore.Load();
+ Username = creds?.Username ?? string.Empty;
+ InitialApiKey = creds?.ApiKey ?? string.Empty;
+
+ var version = typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
+ VersionLine = $"גרסה {version}";
+ }
+
+ [RelayCommand]
+ private async Task TestConnectionAsync()
+ {
+ StatusBrush = NeutralBrush;
+ StatusMessage = "בודק חיבור…";
+
+ if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out var baseUrl))
+ {
+ Fail("כתובת שרת לא תקינה");
+ return;
+ }
+
+ var apiKey = ReadApiKey();
+ if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(apiKey))
+ {
+ Fail("יש להזין שם משתמש ומפתח API");
+ return;
+ }
+
+ HttpClient? http = null;
+ try
+ {
+ http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
+ var probe = new EspoCrmClient(http, baseUrl.ToString(), Username, apiKey, _logger);
+ var user = await probe.TestConnectionAsync().ConfigureAwait(true);
+ Succeed($"התחברות תקינה — משתמש: {user.UserName ?? user.Id}");
+ }
+ catch (EspoCrmAuthorizationException)
+ {
+ Fail("מפתח ה-API או שם המשתמש שגויים");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "TestConnection failed");
+ Fail("שגיאת חיבור: " + ex.Message);
+ }
+ finally
+ {
+ http?.Dispose();
+ }
+ }
+
+ [RelayCommand]
+ private void Save()
+ {
+ if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out _))
+ {
+ Fail("כתובת שרת לא תקינה");
+ return;
+ }
+ var apiKey = ReadApiKey();
+ if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(apiKey))
+ {
+ Fail("יש להזין שם משתמש ומפתח API");
+ return;
+ }
+
+ try
+ {
+ _settings.EspoCrmUrl = EspoCrmUrl.TrimEnd('/');
+ _settings.Locale = string.IsNullOrWhiteSpace(Locale) ? "he-IL" : Locale;
+ _settingsManager.Save(_settings);
+
+ _credentialStore.Save(Username.Trim(), apiKey);
+ _logger.Information("Settings saved (user={User}, url={Url})", Username, _settings.EspoCrmUrl);
+
+ DialogResult = true;
+ RequestClose?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Settings save failed");
+ Fail("שמירת ההגדרות נכשלה: " + ex.Message);
+ }
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ DialogResult = false;
+ RequestClose?.Invoke(this, EventArgs.Empty);
+ }
+
+ private string ReadApiKey() => ApiKeyReader?.Invoke() ?? string.Empty;
+
+ private void Fail(string message)
+ {
+ StatusBrush = ErrorBrush;
+ StatusMessage = message;
+ }
+
+ private void Succeed(string message)
+ {
+ StatusBrush = SuccessBrush;
+ StatusMessage = message;
+ }
+ }
+}
diff --git a/src/OutlookAddin/Ribbon/ExplorerRibbon.cs b/src/OutlookAddin/Ribbon/ExplorerRibbon.cs
index 621f3a3..f032e36 100644
--- a/src/OutlookAddin/Ribbon/ExplorerRibbon.cs
+++ b/src/OutlookAddin/Ribbon/ExplorerRibbon.cs
@@ -67,5 +67,10 @@ namespace OutlookAddin.Ribbon
{
_ = Globals.ThisAddIn.AddInHost.LaunchFileToCaseAsync();
}
+
+ public void OnOpenSettings(IRibbonControl control)
+ {
+ Globals.ThisAddIn.AddInHost.LaunchSettings();
+ }
}
}
diff --git a/src/OutlookAddin/Ribbon/ExplorerRibbon.xml b/src/OutlookAddin/Ribbon/ExplorerRibbon.xml
index 0f6b88c..395a709 100644
--- a/src/OutlookAddin/Ribbon/ExplorerRibbon.xml
+++ b/src/OutlookAddin/Ribbon/ExplorerRibbon.xml
@@ -12,6 +12,13 @@
imageMso="MeetingForwardToManager"
onAction="OnFileToEspoCrm"
getEnabled="OnGetFileToEspoCrmEnabled" />
+
diff --git a/src/OutlookAddin/Services/AddInHost.cs b/src/OutlookAddin/Services/AddInHost.cs
index c70d5b9..2d70e78 100644
--- a/src/OutlookAddin/Services/AddInHost.cs
+++ b/src/OutlookAddin/Services/AddInHost.cs
@@ -55,18 +55,24 @@ namespace OutlookAddin.Services
public bool IsConfigured => CrmClient != null && FilingService != null;
+ public event EventHandler? CrmClientChanged;
+
private void TryInitializeCrm()
{
+ DisposeCrmStack();
+
var creds = CredentialStore.Load();
if (creds == null || string.IsNullOrWhiteSpace(creds.Username) || string.IsNullOrWhiteSpace(creds.ApiKey))
{
Logger.Information("AddInHost: no stored credentials — CRM features disabled until first-run setup");
+ CrmClientChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (!Uri.TryCreate(Settings.EspoCrmUrl, UriKind.Absolute, out var baseUrl))
{
Logger.Warning("AddInHost: settings.EspoCrmUrl is not a valid URL: {Url}", Settings.EspoCrmUrl);
+ CrmClientChanged?.Invoke(this, EventArgs.Empty);
return;
}
@@ -78,13 +84,45 @@ namespace OutlookAddin.Services
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
_retryProcessor.Start();
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
+ CrmClientChanged?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
Logger.Error(ex, "AddInHost: failed to initialize CRM client");
+ DisposeCrmStack();
+ CrmClientChanged?.Invoke(this, EventArgs.Empty);
}
}
+ private void DisposeCrmStack()
+ {
+ try { _retryProcessor?.Dispose(); } catch { /* ignore */ }
+ _retryProcessor = null;
+ try { _httpClient?.Dispose(); } catch { /* ignore */ }
+ _httpClient = null;
+ CrmClient = null;
+ FilingService = null;
+ }
+
+ ///
+ /// Opens the Settings dialog. On Save, tears down and rebuilds the CRM
+ /// client/filing/retry stack so the new credentials take effect immediately.
+ ///
+ public bool LaunchSettings()
+ {
+ var vm = new SettingsViewModel(Settings, SettingsManager, CredentialStore, Logger);
+ var dialog = new SettingsDialog(vm);
+ var ok = dialog.ShowDialog();
+ if (ok != true) return false;
+
+ // Re-read settings from disk in case fields were normalized.
+ var refreshed = SettingsManager.Load();
+ Settings.EspoCrmUrl = refreshed.EspoCrmUrl;
+ Settings.Locale = refreshed.Locale;
+ TryInitializeCrm();
+ return true;
+ }
+
///
/// Entry point invoked by the ribbon "File to EspoCRM" button. Reads the
/// current Explorer selection, opens the search dialog, files on success,
@@ -94,12 +132,16 @@ namespace OutlookAddin.Services
{
if (!IsConfigured)
{
- MessageBox.Show(
- "התוסף עדיין לא מוגדר. יש להזין כתובת EspoCRM, שם משתמש ומפתח API בהגדרות.",
+ var result = MessageBox.Show(
+ "התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Marcus-Law OutlookAddin",
- MessageBoxButton.OK,
+ MessageBoxButton.YesNo,
MessageBoxImage.Information);
- return;
+ if (result == MessageBoxResult.Yes)
+ {
+ LaunchSettings();
+ }
+ if (!IsConfigured) return;
}
var mailItems = ReadCurrentSelection();