О чем?
Статья про то, как связать ваш почтовый ящик и ИИ. В качестве демонстрации выбрал стек Майкрософта, так как почту можно управлять через Microsoft Graph API в реально времени и связать c любым ИИ.
Что понадобится:
Подписка на любой GenAI
Visual Studio
Сервис очередей
Делаем
Архитектура очень простая и выглядеть следующим образом. Microsoft Graph API требует определённый API контракт при подписки на ресурс через webhook-ы, есть и другие способы, подробнее тут [3]. Подписка создается с указанием на публичный API.
Требуемый API контракт, упомянутый в пункте 1, состоит из двух методов lifecycle и listen. Базовая имплементация выглядеть следующим образом:
Lifecycle - метод отвечает за обновление подписки. При создании подписки Майкрософт валидирует доступность предоставленного API отправляя токен для валидации, а при истечении срока подписки отправлет запрос на обновление подписки и следуя нашей логике(строки с 24 по 35) обновляет подписку.
[HttpPost("lifecycle")] [AllowAnonymous] public async Task<IActionResult> Lifecycle([FromQuery] string? validationToken = null) { try { logger.LogInformation($"Received notification for Lifecycle with validationToken: {validationToken}"); // If there is a validation token in the query string, // send it back in a 200 OK text/plain response if (!string.IsNullOrEmpty(validationToken)) { return Ok(validationToken); } using var bodyStreamReader = new StreamReader(Request.Body); var content = await bodyStreamReader.ReadToEndAsync(); var notifications = JsonConvert.DeserializeObject<ChangeNotificationCollection>(content); if (notifications == null || notifications.Value == null) return Accepted(); logger.LogInformation($"Lifecycle notification with validationToken was parsed: {validationToken}"); // Process any lifecycle events var lifecycleNotifications = notifications.Value.Where(n => n.LifecycleEvent != null); foreach (var lifecycleNotification in lifecycleNotifications) { logger.LogInformation("Received {eventType} notification for subscription {subscriptionId}", lifecycleNotification.LifecycleEvent.ToString(), lifecycleNotification.SubscriptionId); if (lifecycleNotification.LifecycleEvent == LifecycleEventType.ReauthorizationRequired) { await RenewSubscriptionAsync(lifecycleNotification); logger.LogInformation($"Lifecycle notification with validationToken was handled: {validationToken}"); } } logger.LogInformation($"Lifecycle notification with validationToken was finished: {validationToken}"); // Return 202 to Graph to confirm receipt of notification. // Not sending this will cause Graph to retry the notification. return Accepted(); } catch (Exception ex) { logger.LogError($"Error listening notification: {ex.Message}", ex.ToString()); return BadRequest(); } }
Listen - метод который получает нотификацию от ресурса к которому мы подписались. Как и предыдущем методе при первом обращении передается токен для валидации. Также, с данными в теле запроса приходить JWT токен в котором содержиться информация для какого приложения был сгенерирован и кем. Зависимости от ресурса, данные могут не прилетать. В нашем случае подписка на почту не передает данные, а только идентификаторы ресурса что мы извлекаем и кладем в очередь для обработки.
[HttpPost("listen")] [AllowAnonymous] public async Task<IActionResult> Listen([FromQuery] string? validationToken = null) { logger.LogDebug($"Listen message:{DateTime.Now}"); try { // If there is a validation token in the query string, // send it back in a 200 OK text/plain response if (!string.IsNullOrEmpty(validationToken)) { return Ok(validationToken); } using var bodyStreamReader = new StreamReader(Request.Body); var content = await bodyStreamReader.ReadToEndAsync(); var notifications = JsonConvert.DeserializeObject<ChangeNotificationCollection>(content); if (notifications == null || notifications.Value == null) return Accepted(); // Validate any tokens in the payload var areTokensValid = await notifications.AreTokensValid(adSettings.Value); if (!areTokensValid) return Unauthorized(); if (notifications.Value == null) { return Accepted(); } // Process non-encrypted notifications first // These will be notifications for user mailbox foreach (var notification in notifications.Value.Where(n => n.EncryptedContent == null)) { var subscription = subscriptionStore.GetSubscriptionRecord(notification.SubscriptionId.ToString() ?? string.Empty); // If this isn't a subscription we know about, or if client state doesn't match, // ignore it if (subscription != null && subscription.ClientState == notification.ClientState) { var messageId = GetMessageId(notification.Resource); await serviceBusSender.SendMessageAsync(messageId); } } return Accepted(); } catch (Exception ex) { logger.LogError($"Error listening notification: {ex.Message}", ex.ToString()); return BadRequest(); } }
Subscription - метод который упростить нам создание подписки. В 8 строке указываем куда Майкрософту обращаться для отправки нотификации и обновления подписки, а также указываем сам ресурс(входящие сообщения).
[HttpPost("create")] public async Task<IActionResult> Create() { try { var notificationHost = configuration.GetValue<string>("NotificationHost"); // Create the subscription var subscription = new Subscription { ChangeType = "created", NotificationUrl = $"{notificationHost}/api/listen", LifecycleNotificationUrl = $"{notificationHost}/api/lifecycle", Resource = "me/mailFolders('Inbox')/messages", ClientState = Guid.NewGuid().ToString(), // Subscription only lasts for one hour ExpirationDateTime = DateTimeOffset.UtcNow.AddDays(1), }; var newSubscription = await graphServiceClient.Subscriptions.PostAsync(subscription); // Add the subscription to the subscription store var user = await graphServiceClient.Me.GetAsync(); if (newSubscription != null && user != null) { subscriptionStore.SaveSubscriptionRecord(new SubscriptionRecord { Id = newSubscription.Id, UserId = user.Id, TenantId = addSettings.Value.TenantId, ClientState = newSubscription.ClientState, }); return Ok(newSubscription); } } catch (Exception e) { logger.LogInformation(e.Message); } return BadRequest(); }
Обработчик сообщений сделаем простым. Если письмо будет содержать информацию о зарплате за текущий месяц, то переводим в папку Важные, иначе удаляем. Сначала определяем тип действия подстваляя содержание письма в промпт. Далее на основе значения либо удаляем, либо переводим в папку.
Два метода выглядить следующим образом.
public async Task<ActionType> DetermineActionTypeAsync(string messageId) { var message = await graphServiceClient.Me.Messages[messageId].GetAsync(); if (message is { Body: not null }) { var prompt = @$"If TEXT below contains salary information for current month return MOVE Otherwise DELETE; Current Date: {DateTime.UtcNow} TEXT:{message.Body.Content}"; var completion = await GetCompleteChatAsync(prompt); if (completion != null) { if (completion.Text.Contains("MOVE")) { return ActionType.Move; } if (completion.Text.Contains("DELETE")) { return ActionType.Delete; } } } return ActionType.None; } public async Task Process(ActionType actionType, string messageId) { switch (actionType) { case ActionType.Delete: await graphServiceClient.Me.Messages[messageId].DeleteAsync(); break; case ActionType.Move: var mailFolders = await graphServiceClient.Me.MailFolders.GetAsync(); if (mailFolders is { Value: not null }) { var importantFolder = mailFolders.Value.FirstOrDefault(mf => mf.DisplayName != null && mf.DisplayName.Contains("Important")); if (importantFolder is not null) { var moveRequest = new MovePostRequestBody { DestinationId = importantFolder.Id }; await graphServiceClient.Me.Messages[messageId].Move.PostAsync(moveRequest); } } break; } }
Для того чтобы все это работало нужно будеть получать токен для авторизации от имени пользователья под приложением. Создаем аккаунт, который не будет требовать MFA в Entra (обязательно указать локацию использования). Это нужно чтобы без интерактивного участия человека получать токен. Если есть другой способ – гуд. Не забываем присвоить лицензию Office для этого пользователя
Создаем приложение в Entra под которым будем получать токен для авторизации.
Redirect URI прописываем callback для того, чтобы мы через Postman при первом обращении, разрешили приложению использовать пользовательскую информацию
Добавляем Mail.ReadWrite, Mail.Send права приложению через делегированный тип
Создаем секреты
Ставим галочку для получения токена
Прописываем все полученные значения в конфигурации приложения и создаем подписку на ресурс. Ответ на запрос создания подписки выглядить следующим образом
{ "applicationId": "b7da45c2-044b-4044-b764-104f7f5ffdb3", "changeType": "created", "clientState": "bdfba96f-0692-423a-9a21-e8b1dd55a0b2", "creatorId": "f99f609d-b429-47fb-87a4-31ab75611ee7", "encryptionCertificate": null, "encryptionCertificateId": null, "expirationDateTime": "2024-08-13T10:04:50.2764731+00:00", "includeResourceData": null, "latestSupportedTlsVersion": "v1_2", "lifecycleNotificationUrl": "https://f01f-195-158-28-2.ngrok-free.app/api/lifecycle", "notificationQueryOptions": null, "notificationUrl": "https://f01f-195-158-28-2.ngrok-free.app/api/listen", "notificationUrlAppId": null, "resource": "me/mailFolders('Inbox')/messages", "additionalData": { "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity" }, "backingStore": { "initializationCompleted": true, "returnOnlyChangedValues": false }, "id": "e9ea7d23-f16c-43d5-8207-3f73cc7a89cc", "odataType": null }
Демо