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, OutlookMapiContactProvider>();
|
||||
services.AddSingleton<IReverseLookupProvider, PublicDirectoryLookupProvider>();
|
||||
|
||||
services.AddSingleton<ContactLookupService>();
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
"ClientSecret": "",
|
||||
"TokenStorePath": "%LocalAppData%\\Anrufmonitor\\google-token"
|
||||
},
|
||||
"OutlookMapi": {
|
||||
"Enabled": false
|
||||
},
|
||||
"PublicDirectory": {
|
||||
"Enabled": false,
|
||||
"RequestUrlTemplate": "",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ public enum ContactSource
|
|||
Office365,
|
||||
Google,
|
||||
PublicDirectory,
|
||||
OutlookLocal,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue