Как стать автором
Обновить

Почта и ИИ

Уровень сложностиПростой

О чем?

Статья про то, как связать ваш почтовый ящик и ИИ. В качестве демонстрации выбрал стек Майкрософта, так как почту можно управлять через Microsoft Graph API в реально времени и связать c любым ИИ.

Что понадобится:

Делаем

  1. Архитектура очень простая и выглядеть следующим образом. Microsoft Graph API требует определённый API контракт при подписки на ресурс через webhook-ы, есть и другие способы, подробнее тут [3]. Подписка создается с указанием на публичный API.

    Архитектурная диаграмма
    Архитектурная диаграмма
  2. Требуемый 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();
    }
  3. Обработчик сообщений сделаем простым. Если письмо будет содержать информацию о зарплате за текущий месяц, то переводим в папку Важные, иначе удаляем. Сначала определяем тип действия подстваляя содержание письма в промпт. Далее на основе значения либо удаляем, либо переводим в папку.

    Два метода выглядить следующим образом.

    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;
        }
    }
  4. Для того чтобы все это работало нужно будеть получать токен для авторизации от имени пользователья под приложением. Создаем аккаунт, который не будет требовать MFA в Entra (обязательно указать локацию использования). Это нужно чтобы без интерактивного участия человека получать токен. Если есть другой способ – гуд. Не забываем присвоить лицензию Office для этого пользователя

    Создание пользователья
    Создание пользователья
    Присвоение лицензии
    Присвоение лицензии
  5. Создаем приложение в Entra под которым будем получать токен для авторизации.

    1. Redirect URI прописываем callback для того, чтобы мы через Postman при первом обращении, разрешили приложению использовать пользовательскую информацию

    2. Добавляем Mail.ReadWrite, Mail.Send права приложению через делегированный тип

    3. Создаем секреты

    4. Ставим галочку для получения токена

      Регистрации приложения 1
      Регистрации приложения 1
      Регистрации приложения 2
      Регистрации приложения 2
  6. Прописываем все полученные значения в конфигурации приложения и создаем подписку на ресурс. Ответ на запрос создания подписки выглядить следующим образом

    {
      "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
    }
  7. Демо

Полезные ссылки

  1. https://github.com/Azizxon/MailAI

  2. https://developer.microsoft.com/en-us/microsoft-365/dev-program

  3. https://learn.microsoft.com/en-us/graph/api/resources/change-notifications-api-overview?view=graph-rest-1.0

  4. https://github.com/microsoftgraph/aspnetcore-webhooks-sample/tree/main/

Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.