Outlook lokal/MAPI-Kontaktprovider (COM Interop), deckt alle lokalen Outlook-Konten inkl. outlook.com ab

This commit is contained in:
dimedtec 2026-08-31 16:56:20 +02:00
parent e4cbec50f4
commit 567018a4aa
9 changed files with 256 additions and 2 deletions

View file

@ -59,6 +59,7 @@ public partial class App : System.Windows.Application
}
services.AddSingleton<IContactProvider, GoogleContactProvider>();
services.AddSingleton<IContactProvider, OutlookMapiContactProvider>();
services.AddSingleton<IReverseLookupProvider, PublicDirectoryLookupProvider>();
services.AddSingleton<ContactLookupService>();

View file

@ -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}",

View file

@ -20,6 +20,9 @@
"ClientSecret": "",
"TokenStorePath": "%LocalAppData%\\Anrufmonitor\\google-token"
},
"OutlookMapi": {
"Enabled": false
},
"PublicDirectory": {
"Enabled": false,
"RequestUrlTemplate": "",

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@ -16,6 +16,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageReference Include="Microsoft.Graph" Version="6.5.0" />
<PackageReference Include="Microsoft.Office.Interop.Outlook" Version="15.0.4797.1004" />
<PackageReference Include="MSOfficeCore.Interop" Version="15.0.2" />
</ItemGroup>
</Project>

View file

@ -8,6 +8,7 @@ public enum ContactSource
Office365,
Google,
PublicDirectory,
OutlookLocal,
}
/// <summary>

View file

@ -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";
}
/// <summary>
/// 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.
/// </summary>
public sealed class OutlookMapiOptions
{
public bool Enabled { get; set; }
}
/// <summary>
/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern.
/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren.

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class OutlookMapiContactProvider(
IOptions<ContactsOptions> options, ILogger<OutlookMapiContactProvider> logger) : IContactProvider
{
public ContactSource Source => ContactSource.OutlookLocal;
public string ProviderKey => "outlook-mapi";
public bool IsEnabled => options.Value.OutlookMapi.Enabled;
public Task<IReadOnlyList<ContactInfo>> GetAllContactsAsync(CancellationToken cancellationToken)
{
if (!IsEnabled)
{
return Task.FromResult<IReadOnlyList<ContactInfo>>([]);
}
return StaThreadRunner.RunAsync(ReadContactsSync);
}
private IReadOnlyList<ContactInfo> ReadContactsSync()
{
var result = new List<ContactInfo>();
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<ContactInfo> 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<string>();
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<string> list, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
list.Add(value);
}
}
private static string SafeGetDisplayName(Outlook.Store store)
{
try
{
return store.DisplayName;
}
catch
{
return "?";
}
}
}

View file

@ -0,0 +1,34 @@
namespace Anrufmonitor.Contacts;
/// <summary>
/// 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.
/// </summary>
internal static class StaThreadRunner
{
public static Task<T> RunAsync<T>(Func<T> action)
{
var tcs = new TaskCompletionSource<T>(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;
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>