From 567018a4aa42b0b72abee2871362a9f22fa3f813 Mon Sep 17 00:00:00 2001 From: dimedtec Date: Mon, 31 Aug 2026 16:56:20 +0200 Subject: [PATCH] Outlook lokal/MAPI-Kontaktprovider (COM Interop), deckt alle lokalen Outlook-Konten inkl. outlook.com ab --- src/Anrufmonitor.App/App.xaml.cs | 1 + .../appsettings.Local.json.example | 4 + src/Anrufmonitor.App/appsettings.json | 3 + .../Anrufmonitor.Contacts.csproj | 4 +- src/Anrufmonitor.Contacts/ContactInfo.cs | 1 + src/Anrufmonitor.Contacts/ContactsOptions.cs | 15 ++ .../OutlookMapiContactProvider.cs | 194 ++++++++++++++++++ src/Anrufmonitor.Contacts/StaThreadRunner.cs | 34 +++ .../Anrufmonitor.Tests.csproj | 2 +- 9 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/Anrufmonitor.Contacts/OutlookMapiContactProvider.cs create mode 100644 src/Anrufmonitor.Contacts/StaThreadRunner.cs diff --git a/src/Anrufmonitor.App/App.xaml.cs b/src/Anrufmonitor.App/App.xaml.cs index 28389f7..a0cc363 100644 --- a/src/Anrufmonitor.App/App.xaml.cs +++ b/src/Anrufmonitor.App/App.xaml.cs @@ -59,6 +59,7 @@ public partial class App : System.Windows.Application } services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Anrufmonitor.App/appsettings.Local.json.example b/src/Anrufmonitor.App/appsettings.Local.json.example index 2bac3f3..cb1e726 100644 --- a/src/Anrufmonitor.App/appsettings.Local.json.example +++ b/src/Anrufmonitor.App/appsettings.Local.json.example @@ -27,6 +27,10 @@ "ClientId": "xxxxxxxx.apps.googleusercontent.com", "ClientSecret": "GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, + "OutlookMapi": { + "Enabled": true, + "_comment": "Liest Kontakte direkt aus dem lokal installierten klassischen Outlook (COM/MAPI), deckt alle dort eingerichteten Konten ab. Kein Azure/OAuth noetig, aber ggf. Outlook-Sicherheitsdialog bestaetigen." + }, "PublicDirectory": { "Enabled": false, "RequestUrlTemplate": "https://example-directory.local/api/lookup?number={number}", diff --git a/src/Anrufmonitor.App/appsettings.json b/src/Anrufmonitor.App/appsettings.json index b71470e..8988208 100644 --- a/src/Anrufmonitor.App/appsettings.json +++ b/src/Anrufmonitor.App/appsettings.json @@ -20,6 +20,9 @@ "ClientSecret": "", "TokenStorePath": "%LocalAppData%\\Anrufmonitor\\google-token" }, + "OutlookMapi": { + "Enabled": false + }, "PublicDirectory": { "Enabled": false, "RequestUrlTemplate": "", diff --git a/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj b/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj index 8684467..9f5dce4 100644 --- a/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj +++ b/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj @@ -1,7 +1,7 @@  - net10.0 + net10.0-windows enable enable @@ -16,6 +16,8 @@ + + diff --git a/src/Anrufmonitor.Contacts/ContactInfo.cs b/src/Anrufmonitor.Contacts/ContactInfo.cs index 1016bef..a1c5d4f 100644 --- a/src/Anrufmonitor.Contacts/ContactInfo.cs +++ b/src/Anrufmonitor.Contacts/ContactInfo.cs @@ -8,6 +8,7 @@ public enum ContactSource Office365, Google, PublicDirectory, + OutlookLocal, } /// diff --git a/src/Anrufmonitor.Contacts/ContactsOptions.cs b/src/Anrufmonitor.Contacts/ContactsOptions.cs index 027df88..73f847e 100644 --- a/src/Anrufmonitor.Contacts/ContactsOptions.cs +++ b/src/Anrufmonitor.Contacts/ContactsOptions.cs @@ -22,6 +22,8 @@ public sealed class ContactsOptions public GoogleOptions Google { get; set; } = new(); + public OutlookMapiOptions OutlookMapi { get; set; } = new(); + public PublicDirectoryOptions PublicDirectory { get; set; } = new(); public string ResolveCacheDatabasePath() => @@ -68,6 +70,19 @@ public sealed class GoogleOptions public string TokenStorePath { get; set; } = "%LocalAppData%\\Anrufmonitor\\google-token"; } +/// +/// Liest Kontakte direkt aus dem lokal installierten, klassischen Outlook (COM/MAPI) statt +/// ueber die Cloud-API. Deckt automatisch ALLE im lokalen Outlook-Profil eingerichteten +/// Konten ab (Firmen-Postfach UND/ODER outlook.com, falls dort als Konto hinzugefuegt) - +/// ohne Azure App-Registration/OAuth. Erfordert klassisches Outlook (Win32), nicht "neues Outlook". +/// Outlook zeigt beim programmatischen Zugriff ggf. einen Sicherheitsdialog an, der bestaetigt +/// werden muss. +/// +public sealed class OutlookMapiOptions +{ + public bool Enabled { get; set; } +} + /// /// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern. /// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren. diff --git a/src/Anrufmonitor.Contacts/OutlookMapiContactProvider.cs b/src/Anrufmonitor.Contacts/OutlookMapiContactProvider.cs new file mode 100644 index 0000000..869518a --- /dev/null +++ b/src/Anrufmonitor.Contacts/OutlookMapiContactProvider.cs @@ -0,0 +1,194 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Outlook = Microsoft.Office.Interop.Outlook; + +namespace Anrufmonitor.Contacts; + +/// +/// Liest Kontakte direkt aus dem lokal installierten, klassischen Outlook per COM/MAPI - +/// ohne Cloud-API, ohne OAuth. Iteriert ueber ALLE im lokalen Outlook-Profil eingerichteten +/// Postfaecher/Konten und deren jeweiligen Kontakte-Ordner, deckt also automatisch sowohl +/// ein Firmenkonto als auch outlook.com ab, sofern beide lokal in Outlook eingerichtet sind. +/// Voraussetzung: klassisches (Win32) Outlook installiert und mind. einmal eingerichtet. +/// Outlook kann beim Zugriff einen Sicherheitsdialog anzeigen ("Ein Programm versucht +/// zuzugreifen..."), der manuell bestaetigt werden muss. +/// +public sealed class OutlookMapiContactProvider( + IOptions options, ILogger logger) : IContactProvider +{ + public ContactSource Source => ContactSource.OutlookLocal; + + public string ProviderKey => "outlook-mapi"; + + public bool IsEnabled => options.Value.OutlookMapi.Enabled; + + public Task> GetAllContactsAsync(CancellationToken cancellationToken) + { + if (!IsEnabled) + { + return Task.FromResult>([]); + } + + return StaThreadRunner.RunAsync(ReadContactsSync); + } + + private IReadOnlyList ReadContactsSync() + { + var result = new List(); + + logger.LogInformation("Outlook (lokal/MAPI): verbinde ueber COM ..."); + var app = new Outlook.Application(); + logger.LogInformation("Outlook (lokal/MAPI): Application-Objekt erhalten"); + + try + { + var ns = app.GetNamespace("MAPI"); + logger.LogInformation("Outlook (lokal/MAPI): MAPI-Namespace erhalten"); + + try + { + var stores = ns.Stores; + var storeCount = stores?.Count ?? 0; + logger.LogInformation("Outlook (lokal/MAPI): {Count} Store(s) gefunden", storeCount); + + if (stores is not null) + { + for (var i = 1; i <= storeCount; i++) + { + Outlook.Store? store = null; + string storeName = "?"; + try + { + store = stores[i]; + storeName = SafeGetDisplayName(store); + logger.LogInformation("Outlook (lokal/MAPI): lese Store '{Store}'", storeName); + + var contactsFolder = store.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts); + try + { + ReadFolder(contactsFolder, storeName, result); + } + finally + { + if (contactsFolder is not null) + { + Marshal.ReleaseComObject(contactsFolder); + } + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Kein Kontakte-Ordner in Store '{Store}' (uebersprungen)", storeName); + } + finally + { + if (store is not null) + { + Marshal.ReleaseComObject(store); + } + } + } + + Marshal.ReleaseComObject(stores); + } + } + finally + { + Marshal.ReleaseComObject(ns); + } + } + finally + { + Marshal.ReleaseComObject(app); + } + + logger.LogInformation("Outlook (lokal/MAPI): {Count} Kontakte gelesen", result.Count); + return result; + } + + private void ReadFolder(Outlook.MAPIFolder? folder, string storeName, List result) + { + if (folder is null) + { + return; + } + + var items = folder.Items; + try + { + foreach (var item in items) + { + if (item is Outlook.ContactItem contact) + { + try + { + var mapped = MapContact(contact, storeName); + if (mapped is not null) + { + result.Add(mapped); + } + } + finally + { + Marshal.ReleaseComObject(contact); + } + } + else if (item is not null && Marshal.IsComObject(item)) + { + Marshal.ReleaseComObject(item); + } + } + } + finally + { + Marshal.ReleaseComObject(items); + } + } + + private static ContactInfo? MapContact(Outlook.ContactItem contact, string storeName) + { + var numbers = new List(); + AddIfPresent(numbers, contact.BusinessTelephoneNumber); + AddIfPresent(numbers, contact.Business2TelephoneNumber); + AddIfPresent(numbers, contact.MobileTelephoneNumber); + AddIfPresent(numbers, contact.HomeTelephoneNumber); + AddIfPresent(numbers, contact.Home2TelephoneNumber); + AddIfPresent(numbers, contact.OtherTelephoneNumber); + + if (numbers.Count == 0) + { + return null; + } + + var displayName = !string.IsNullOrWhiteSpace(contact.FullName) ? contact.FullName : contact.FileAs; + + return new ContactInfo( + Id: $"mapi:{storeName}:{contact.EntryID}", + Source: ContactSource.OutlookLocal, + DisplayName: string.IsNullOrWhiteSpace(displayName) ? "(ohne Namen)" : displayName, + Company: string.IsNullOrWhiteSpace(contact.CompanyName) ? null : contact.CompanyName, + PhoneNumbers: numbers, + PhotoUrl: null); + } + + private static void AddIfPresent(List list, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + list.Add(value); + } + } + + private static string SafeGetDisplayName(Outlook.Store store) + { + try + { + return store.DisplayName; + } + catch + { + return "?"; + } + } +} diff --git a/src/Anrufmonitor.Contacts/StaThreadRunner.cs b/src/Anrufmonitor.Contacts/StaThreadRunner.cs new file mode 100644 index 0000000..a60ba27 --- /dev/null +++ b/src/Anrufmonitor.Contacts/StaThreadRunner.cs @@ -0,0 +1,34 @@ +namespace Anrufmonitor.Contacts; + +/// +/// Fuehrt eine Aktion auf einem dedizierten STA-Thread (Single-Threaded Apartment) aus. +/// Noetig fuer Outlook-COM-Interop: COM-Objekte wie Outlook.Application erfordern STA, +/// der BackgroundService (ContactSyncService) laeuft aber auf dem normalen (MTA) Thread-Pool. +/// +internal static class StaThreadRunner +{ + public static Task RunAsync(Func action) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var thread = new Thread(() => + { + try + { + tcs.SetResult(action()); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }) + { + IsBackground = true, + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return tcs.Task; + } +} diff --git a/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj b/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj index 2b6934d..6e866be 100644 --- a/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj +++ b/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj @@ -1,7 +1,7 @@  - net10.0 + net10.0-windows enable enable false