Outlook lokal/MAPI-Kontaktprovider (COM Interop), deckt alle lokalen Outlook-Konten inkl. outlook.com ab
This commit is contained in:
parent
e4cbec50f4
commit
567018a4aa
9 changed files with 256 additions and 2 deletions
|
|
@ -59,6 +59,7 @@ public partial class App : System.Windows.Application
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddSingleton<IContactProvider, GoogleContactProvider>();
|
services.AddSingleton<IContactProvider, GoogleContactProvider>();
|
||||||
|
services.AddSingleton<IContactProvider, OutlookMapiContactProvider>();
|
||||||
services.AddSingleton<IReverseLookupProvider, PublicDirectoryLookupProvider>();
|
services.AddSingleton<IReverseLookupProvider, PublicDirectoryLookupProvider>();
|
||||||
|
|
||||||
services.AddSingleton<ContactLookupService>();
|
services.AddSingleton<ContactLookupService>();
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@
|
||||||
"ClientId": "xxxxxxxx.apps.googleusercontent.com",
|
"ClientId": "xxxxxxxx.apps.googleusercontent.com",
|
||||||
"ClientSecret": "GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
"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": {
|
"PublicDirectory": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"RequestUrlTemplate": "https://example-directory.local/api/lookup?number={number}",
|
"RequestUrlTemplate": "https://example-directory.local/api/lookup?number={number}",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
"ClientSecret": "",
|
"ClientSecret": "",
|
||||||
"TokenStorePath": "%LocalAppData%\\Anrufmonitor\\google-token"
|
"TokenStorePath": "%LocalAppData%\\Anrufmonitor\\google-token"
|
||||||
},
|
},
|
||||||
|
"OutlookMapi": {
|
||||||
|
"Enabled": false
|
||||||
|
},
|
||||||
"PublicDirectory": {
|
"PublicDirectory": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"RequestUrlTemplate": "",
|
"RequestUrlTemplate": "",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||||
<PackageReference Include="Microsoft.Graph" Version="6.5.0" />
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ public enum ContactSource
|
||||||
Office365,
|
Office365,
|
||||||
Google,
|
Google,
|
||||||
PublicDirectory,
|
PublicDirectory,
|
||||||
|
OutlookLocal,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ public sealed class ContactsOptions
|
||||||
|
|
||||||
public GoogleOptions Google { get; set; } = new();
|
public GoogleOptions Google { get; set; } = new();
|
||||||
|
|
||||||
|
public OutlookMapiOptions OutlookMapi { get; set; } = new();
|
||||||
|
|
||||||
public PublicDirectoryOptions PublicDirectory { get; set; } = new();
|
public PublicDirectoryOptions PublicDirectory { get; set; } = new();
|
||||||
|
|
||||||
public string ResolveCacheDatabasePath() =>
|
public string ResolveCacheDatabasePath() =>
|
||||||
|
|
@ -68,6 +70,19 @@ public sealed class GoogleOptions
|
||||||
public string TokenStorePath { get; set; } = "%LocalAppData%\\Anrufmonitor\\google-token";
|
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>
|
/// <summary>
|
||||||
/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern.
|
/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern.
|
||||||
/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren.
|
/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren.
|
||||||
|
|
|
||||||
194
src/Anrufmonitor.Contacts/OutlookMapiContactProvider.cs
Normal file
194
src/Anrufmonitor.Contacts/OutlookMapiContactProvider.cs
Normal 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 "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Anrufmonitor.Contacts/StaThreadRunner.cs
Normal file
34
src/Anrufmonitor.Contacts/StaThreadRunner.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue