zammad/lib/microsoft_graph.rb
2026-01-02 15:41:09 +02:00

216 lines
5.8 KiB
Ruby

# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class MicrosoftGraph
BASE_URL = 'https://graph.microsoft.com/v1.0/'.freeze
attr_reader :bearer_token, :mailbox
def initialize(access_token:, mailbox:)
super()
@bearer_token = access_token
@mailbox = mailbox
end
def send_message(mail)
headers = { 'Content-Type' => 'text/plain' }
encoded = Base64.strict_encode64(mail.to_s)
options = { headers:, send_as_raw_body: encoded }
make_request('sendMail', method: :post, json: false, options:)
end
def store_mocked_message(params, folder_id: 'inbox')
make_request("mailFolders/#{folder_id}/messages", method: :post, params:)
end
def list_messages(unread_only: false, per_page: 1000, follow_pagination: true, folder_id: nil, select: 'id')
path = 'messages/?$count=true&$orderby=receivedDateTime ASC'
path += "&$select=#{select}"
path += "&top=#{per_page}"
filters = []
filters << 'isRead eq false' if unread_only
filters << "parentFolderId eq '#{folder_id || 'inbox'}'"
if filters.any?
path += "&$filter=(#{filters.join(' AND ')})"
end
make_paginated_request(path, follow_pagination:)
end
def create_message_folder(name, parent_folder_id: nil)
path = if parent_folder_id.present?
"mailFolders/#{parent_folder_id}/childFolders"
else
'mailFolders'
end
params = { displayName: name }
make_request(path, method: :post, params:)
end
def get_message_folder_details(id)
make_request("mailFolders/#{id}")
end
def delete_message_folder(id)
make_request("mailFolders/#{id}", method: :delete)
end
def get_message_folders_tree(parent = nil)
path = if parent.present?
"mailFolders/#{parent}/childFolders"
else
'mailFolders'
end
make_paginated_request("#{path}?$expand=childFolders($top=9999)&$top=9999")
.fetch(:items, [])
.map do |elem|
{
id: elem[:id],
displayName: elem[:displayName],
childFolders: elem[:childFolders].map do |expanded_elem|
if expanded_elem[:childFolderCount].positive?
expanded_folders = get_message_folders_tree(expanded_elem[:id])
end
{
id: expanded_elem[:id],
displayName: expanded_elem[:displayName],
childFolders: expanded_folders || []
}
end
}
end
end
def get_message_basic_details(message_id)
result = make_request("messages/#{message_id}/?$expand=singleValueExtendedProperties($filter=Id%20eq%20'LONG%200x0E08')&$select=internetMessageHeaders")
size = result.fetch(:singleValueExtendedProperties)
.find { |elem| elem[:id] == 'Long 0xe08' }
&.dig(:value)
&.to_i
headers = headers_to_hash result.fetch(:internetMessageHeaders, {})
{ headers:, size: }
end
def get_raw_message(message_id)
make_request("messages/#{message_id}/$value", json: false)
end
def mark_message_as_read(message_id)
make_request("messages/#{message_id}", method: :patch, params: { isRead: true })
end
def delete_message(message_id)
make_request("messages/#{message_id}", method: :delete)
end
private
def make_request(path, method: :get, json: true, params: {}, options: {})
options[:bearer_token] = bearer_token
options[:json] = json
uri = URI(path).host.present? ? path : "#{BASE_URL}#{mailbox_path}#{path}"
response = UserAgent.send(method, uri, params, options)
if !response.success?
handle_error!(response)
end
if json && (data = response.data.presence)
return data.with_indifferent_access
end
response.body
end
def handle_error!(response)
error_details = parse_error(response)
retry_date = parse_retry_headers(response)
raise ApiError.new(error_details, retry_after: retry_date)
end
def parse_error(response)
if response&.body&.start_with?('{')
return JSON.parse(response.body)['error']
end
{
code: response.code,
message: response.body || response.error,
}
end
RETRY_AFTER_SECONDS_REGEXP = %r{^\d+$}
def parse_retry_headers(response)
# Official header name is Retry-After.
# But HTTP header names are case-insensitive.
# Thus Net::HTTP normalizes it to lowercase.
retry_date = response.header['retry-after']&.strip&.presence
return nil if retry_date.blank?
# HTTP Retry-After header shall be a date.
# However, Microssoft Graph API usually returns seconds to wait.
# To make things even better, Microsoft itself recommends clients to also support date as per RFC :)
if retry_date.match?(RETRY_AFTER_SECONDS_REGEXP)
return retry_date.to_i.seconds.from_now
end
Time.zone.parse(retry_date)
rescue
nil
end
PAGINATED_MAX_LOOPS = 25
def make_paginated_request(path, follow_pagination: true, **)
response = make_request(path, **)
total_count = response[:'@odata.count']
return { total_count:, items: response.fetch(:value) } if !follow_pagination
received = [response]
while (pagination_link = response['@odata.nextLink'])
response = make_request(pagination_link)
received << response
if received.count > PAGINATED_MAX_LOOPS # rubocop:disable Style/Next
error = {
code: 'X-Zammad-MsGraphEndlessLoop',
message: "Microsoft Graph API paginated response caused a permanenet loop: #{path}"
}
raise ApiError, error
end
end
items = received.flat_map { |elem| elem.fetch(:value) }
{ total_count:, items: }
end
def headers_to_hash(input)
input.each_with_object({}.with_indifferent_access) do |elem, memo|
memo[elem[:name]] = elem[:value]
end
end
def mailbox_path
"users/#{mailbox}/"
end
end