Browse Source

Fixes #5379 - New M365 Channel using GraphAPI instead of IMAP/SMTP

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Dominik Klein 2 months ago
parent
commit
67bd6ea64a

+ 4 - 4
.dev/rubocop/todo.yml

@@ -126,7 +126,7 @@ Metrics/AbcSize:
     - 'lib/external_credential/exchange.rb'
     - 'lib/external_credential/exchange.rb'
     - 'lib/external_credential/facebook.rb'
     - 'lib/external_credential/facebook.rb'
     - 'lib/external_credential/google.rb'
     - 'lib/external_credential/google.rb'
-    - 'lib/external_credential/microsoft365.rb'
+    - 'lib/external_credential/microsoft_base.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/facebook.rb'
     - 'lib/facebook.rb'
     - 'lib/fill_db.rb'
     - 'lib/fill_db.rb'
@@ -317,7 +317,7 @@ Metrics/CyclomaticComplexity:
     - 'lib/email_helper/probe.rb'
     - 'lib/email_helper/probe.rb'
     - 'lib/excel_sheet.rb'
     - 'lib/excel_sheet.rb'
     - 'lib/external_credential/google.rb'
     - 'lib/external_credential/google.rb'
-    - 'lib/external_credential/microsoft365.rb'
+    - 'lib/external_credential/microsoft_base.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/facebook.rb'
     - 'lib/facebook.rb'
     - 'lib/fill_db.rb'
     - 'lib/fill_db.rb'
@@ -450,7 +450,7 @@ Metrics/PerceivedComplexity:
     - 'lib/email_helper/probe.rb'
     - 'lib/email_helper/probe.rb'
     - 'lib/excel_sheet.rb'
     - 'lib/excel_sheet.rb'
     - 'lib/external_credential/google.rb'
     - 'lib/external_credential/google.rb'
-    - 'lib/external_credential/microsoft365.rb'
+    - 'lib/external_credential/microsoft_base.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/facebook.rb'
     - 'lib/facebook.rb'
     - 'lib/fill_db.rb'
     - 'lib/fill_db.rb'
@@ -525,7 +525,7 @@ Style/OptionalBooleanParameter:
     - 'lib/core_ext/string.rb'
     - 'lib/core_ext/string.rb'
     - 'lib/external_credential/facebook.rb'
     - 'lib/external_credential/facebook.rb'
     - 'lib/external_credential/google.rb'
     - 'lib/external_credential/google.rb'
-    - 'lib/external_credential/microsoft365.rb'
+    - 'lib/external_credential/microsoft_base.rb'
     - 'lib/external_credential/exchange.rb'
     - 'lib/external_credential/exchange.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/external_credential/twitter.rb'
     - 'lib/html_sanitizer.rb'
     - 'lib/html_sanitizer.rb'

+ 1 - 1
app/assets/javascripts/app/controllers/_application_controller/_modal_generic_destroy_confirm.coffee

@@ -1,7 +1,7 @@
 class App.ControllerGenericDestroyConfirm extends App.ControllerModal
 class App.ControllerGenericDestroyConfirm extends App.ControllerModal
   buttonClose: true
   buttonClose: true
   buttonCancel: true
   buttonCancel: true
-  buttonSubmit: __('delete')
+  buttonSubmit: __('Delete')
   buttonClass: 'btn--danger'
   buttonClass: 'btn--danger'
   head: __('Confirmation')
   head: __('Confirmation')
   small: true
   small: true

+ 16 - 0
app/assets/javascripts/app/controllers/_channel/form_handler_channel_account_mailbox_type.coffee

@@ -0,0 +1,16 @@
+class App.FormHandlerChannelAccountMailboxType
+  @run: (params, attribute, attributes, classname, form, ui) ->
+    return if attribute.name isnt 'mailbox_type'
+
+    return if ui.FormHandlerChannelAccountMailboxTypeDone
+    ui.FormHandlerChannelAccountMailboxTypeDone = true
+
+    $(form).find('select[name=mailbox_type]').off('change.mailbox_type').on('change.mailbox_type', (e) ->
+      mailbox_type = $(e.target).val()
+      for attr in attributes
+        continue if attr.name isnt 'shared_mailbox'
+        attr.hide = mailbox_type isnt 'shared'
+        attr.null = mailbox_type isnt 'shared'
+        newElement = ui.formGenItem(attr, classname, form)
+        form.find('div.form-group[data-attribute-name="' + attr.name + '"]').replaceWith(newElement)
+    )

+ 3 - 3
app/assets/javascripts/app/controllers/_channel/google.coffee

@@ -1,10 +1,10 @@
 class App.ChannelGoogle extends App.ControllerTabs
 class App.ChannelGoogle extends App.ControllerTabs
   @requiredPermission: 'admin.channel_google'
   @requiredPermission: 'admin.channel_google'
-  header: __('Google')
+  header: __('Google Email')
   constructor: ->
   constructor: ->
     super
     super
 
 
-    @title __('Google'), true
+    @title __('Google Email'), true
 
 
     @tabs = [
     @tabs = [
       {
       {
@@ -429,4 +429,4 @@ class AppConfig extends App.ControllerModal
         @el.find('.alert').removeClass('hidden').text(data.error || __('App could not be verified.'))
         @el.find('.alert').removeClass('hidden').text(data.error || __('App could not be verified.'))
     )
     )
 
 
-App.Config.set('google', { prio: 5000, name: __('Google'), parent: '#channels', target: '#channels/google', controller: App.ChannelGoogle, permission: ['admin.channel_google'] }, 'NavBarAdmin')
+App.Config.set('google', { prio: 5000, name: __('Google Email'), parent: '#channels', target: '#channels/google', controller: App.ChannelGoogle, permission: ['admin.channel_google'] }, 'NavBarAdmin')

+ 51 - 17
app/assets/javascripts/app/controllers/_channel/microsoft365.coffee

@@ -1,10 +1,10 @@
 class App.ChannelMicrosoft365 extends App.ControllerTabs
 class App.ChannelMicrosoft365 extends App.ControllerTabs
   @requiredPermission: 'admin.channel_microsoft365'
   @requiredPermission: 'admin.channel_microsoft365'
-  header: __('Microsoft 365')
+  header: __('Microsoft 365 IMAP Email')
   constructor: ->
   constructor: ->
     super
     super
 
 
-    @title __('Microsoft 365'), true
+    @title __('Microsoft 365 IMAP Email'), true
 
 
     @tabs = [
     @tabs = [
       {
       {
@@ -123,17 +123,21 @@ class ChannelAccountOverview extends App.ControllerSubContent
     # is already correct for them.
     # is already correct for them.
     if @channel_id
     if @channel_id
       item = App.Channel.find(@channel_id)
       item = App.Channel.find(@channel_id)
-      if item && item.area == 'Microsoft365::Account' && item.options && item.options.backup_imap_classic is undefined
-        @editInbound(undefined, @channel_id, true)
+      if item && item.area == 'Microsoft365::Account' && item.options && item.options.backup_imap_classic is undefined && not @error_code
+        @editInbound(undefined, @channel_id, true, true)
         @channel_id = undefined
         @channel_id = undefined
 
 
     if @error_code is 'AADSTS65004'
     if @error_code is 'AADSTS65004'
       @error_code = undefined
       @error_code = undefined
-      new App.AdminConsentInfo(container: @container)
+      new App.AdminConsentInfo(container: @container, type: 'microsoft365')
 
 
     if @error_code is 'user_mismatch'
     if @error_code is 'user_mismatch'
       @error_code = undefined
       @error_code = undefined
-      new App.UserMismatchInfo(container: @container)
+      new App.UserMismatchInfo(container: @container, type: 'microsoft365', item: item)
+
+    if @error_code is 'duplicate_email_address'
+      @error_code = undefined
+      new App.DuplicateEmailAddressInfo(container: @container, type: 'microsoft365', emailAddress: if @param then decodeURIComponent(@param))
 
 
   show: (params) =>
   show: (params) =>
     for key, value of params
     for key, value of params
@@ -203,7 +207,7 @@ class ChannelAccountOverview extends App.ControllerSubContent
         @load()
         @load()
     )
     )
 
 
-  editInbound: (e, channel_id, set_active) =>
+  editInbound: (e, channel_id, set_active, redirect = false) =>
     if !channel_id
     if !channel_id
       e.preventDefault()
       e.preventDefault()
       channel_id = $(e.target).closest('.action').data('id')
       channel_id = $(e.target).closest('.action').data('id')
@@ -213,6 +217,7 @@ class ChannelAccountOverview extends App.ControllerSubContent
       item: item
       item: item
       callback: @load
       callback: @load
       set_active: set_active,
       set_active: set_active,
+      redirect: redirect,
     )
     )
 
 
   rollbackMigration: (e) =>
   rollbackMigration: (e) =>
@@ -331,15 +336,17 @@ class ChannelInboundEdit extends App.ControllerModal
         @callback(true)
         @callback(true)
         @close()
         @close()
       error: (xhr) =>
       error: (xhr) =>
+        data = JSON.parse(xhr.responseText)
         @stopLoading()
         @stopLoading()
         @formEnable(e)
         @formEnable(e)
-        details = xhr.responseJSON || {}
-        @notify
-          type:    'error'
-          msg:     details.error_human || details.error || __('The changes could not be saved.')
-          timeout: 6000
+        @el.find('.alert--danger').removeClass('hide').text(data.error_human || data.error || __('The changes could not be saved.'))
     )
     )
 
 
+  onCancel: =>
+    return if not @redirect
+
+    @navigate '#channels/microsoft365'
+
 class ChannelGroupEdit extends App.ControllerModal
 class ChannelGroupEdit extends App.ControllerModal
   buttonClose: true
   buttonClose: true
   buttonCancel: true
   buttonCancel: true
@@ -388,7 +395,7 @@ class ChannelGroupEdit extends App.ControllerModal
       error: (xhr) =>
       error: (xhr) =>
         data = JSON.parse(xhr.responseText)
         data = JSON.parse(xhr.responseText)
         @formEnable(e)
         @formEnable(e)
-        @el.find('.alert').removeClass('hidden').text(data.error || __('The changes could not be saved.'))
+        @el.find('.alert--danger').removeClass('hide').text(data.error || __('The changes could not be saved.'))
     )
     )
 
 
 class AppConfig extends App.ControllerModal
 class AppConfig extends App.ControllerModal
@@ -434,11 +441,11 @@ class AppConfig extends App.ControllerModal
               @isChanged = true
               @isChanged = true
               @close()
               @close()
             fail: =>
             fail: =>
-              @el.find('.alert').removeClass('hidden').text(__('The entry could not be created.'))
+              @el.find('.alert--danger').removeClass('hide').text(__('The entry could not be created.'))
           )
           )
           return
           return
         @formEnable(e)
         @formEnable(e)
-        @el.find('.alert').removeClass('hidden').text(data.error || __('App could not be verified.'))
+        @el.find('.alert--danger').removeClass('hide').text(data.error || __('App could not be verified.'))
     )
     )
 
 
 class App.AdminConsentInfo extends App.ControllerModal
 class App.AdminConsentInfo extends App.ControllerModal
@@ -453,6 +460,11 @@ class App.AdminConsentInfo extends App.ControllerModal
   onSubmit: =>
   onSubmit: =>
     @close()
     @close()
 
 
+  onClosed: =>
+    return if not @type
+
+    @navigate "#channels/#{@type}"
+
 class App.UserMismatchInfo extends App.ControllerModal
 class App.UserMismatchInfo extends App.ControllerModal
   buttonClose: true
   buttonClose: true
   small: true
   small: true
@@ -460,9 +472,31 @@ class App.UserMismatchInfo extends App.ControllerModal
   head: __('User Mismatch')
   head: __('User Mismatch')
 
 
   content: ->
   content: ->
-    App.view('microsoft365/user_mismatch')()
+    App.view('microsoft365/user_mismatch')(item: @item)
+
+  onSubmit: =>
+    @close()
+
+  onClosed: =>
+    return if not @type
+
+    @navigate "#channels/#{@type}"
+
+class App.DuplicateEmailAddressInfo extends App.ControllerModal
+  buttonClose: true
+  small: true
+  buttonSubmit: __('Close')
+  head: __('Duplicate Email Address')
+
+  content: ->
+    App.view('microsoft365/duplicate_email_address')(emailAddress: @emailAddress)
 
 
   onSubmit: =>
   onSubmit: =>
     @close()
     @close()
 
 
-App.Config.set('microsoft365', { prio: 5000, name: __('Microsoft 365'), parent: '#channels', target: '#channels/microsoft365', controller: App.ChannelMicrosoft365, permission: ['admin.channel_microsoft365'] }, 'NavBarAdmin')
+  onClosed: =>
+    return if not @type
+
+    @navigate "#channels/#{@type}"
+
+App.Config.set('microsoft365', { prio: 5000, name: __('Microsoft 365 IMAP Email'), parent: '#channels', target: '#channels/microsoft365', controller: App.ChannelMicrosoft365, permission: ['admin.channel_microsoft365'] }, 'NavBarAdmin')

+ 531 - 0
app/assets/javascripts/app/controllers/_channel/microsoft_graph.coffee

@@ -0,0 +1,531 @@
+class App.ChannelMicrosoftGraph extends App.ControllerTabs
+  @requiredPermission: 'admin.channel_microsoft_graph'
+
+  header: __('Microsoft 365 Graph Email')
+
+  constructor: ->
+    super
+
+    @title __('Microsoft 365 Graph Email'), true
+
+    @tabs = [
+      {
+        name:       __('Accounts'),
+        target:     'c-account',
+        controller: ChannelAccountOverview,
+      },
+      {
+        name:       __('Filter'),
+        target:     'c-filter',
+        controller: App.ChannelEmailFilter,
+      },
+      {
+        name:       __('Signatures'),
+        target:     'c-signature',
+        controller: App.ChannelEmailSignature,
+      },
+      {
+        name:       __('Settings'),
+        target:     'c-setting',
+        controller: App.SettingsArea,
+        params:     { area: 'Email::Base' },
+      },
+    ]
+
+    @render()
+
+
+class ChannelAccountOverview extends App.ControllerSubContent
+  @requiredPermission: 'admin.channel_microsoft_graph'
+
+  events:
+    'click .js-new':                'new'
+    'click .js-admin-consent':      'adminConsent'
+    'click .js-editInbound':        'editInbound'
+    'click .js-configApp':          'configApp'
+    'click .js-delete':             'delete'
+    'click .js-reauthenticate':     'reauthenticate'
+    'click .js-disable':            'disable'
+    'click .js-enable':             'enable'
+    'click .js-emailAddressNew':    'emailAddressNew'
+    'click .js-emailAddressEdit':   'emailAddressEdit'
+    'click .js-emailAddressDelete': 'emailAddressDelete',
+    'click .js-channelGroupChange': 'groupChange'
+
+  constructor: ->
+    super
+
+    @interval(@load, 30000)
+    @load()
+
+  load: (reset_channel_id = false) =>
+    if reset_channel_id
+      @channel_id = undefined
+      @navigate '#channels/microsoft_graph'
+
+    @startLoading()
+    @ajax(
+      id:   'microsoft_graph_index'
+      type: 'GET'
+      url:  "#{@apiPath}/channels/admin/microsoft_graph"
+      processData: true
+      success: (data, status, xhr) =>
+        @stopLoading()
+        App.Collection.loadAssets(data.assets)
+        @callbackUrl = data.callback_url
+        @render(data)
+    )
+
+  new: (e) ->
+    new ChannelInboundNew(
+      container: @el.closest('.content')
+    )
+
+  adminConsent: (e) ->
+    window.location.href = "#{@apiPath}/external_credentials/microsoft_graph/link_account?prompt=consent"
+
+  delete: (e) =>
+    e.preventDefault()
+    id   = $(e.target).closest('.action').data('id')
+    new App.ControllerConfirm(
+      message:     __('Are you sure?')
+      buttonClass: 'btn--danger'
+      callback: =>
+        @ajax(
+          id:   'microsoft_graph_delete'
+          type: 'DELETE'
+          url:  "#{@apiPath}/channels/admin/microsoft_graph/#{id}"
+          processData: true
+          success: =>
+            @load()
+        )
+      container: @el.closest('.content')
+    )
+
+  emailAddressNew: (e) =>
+    e.preventDefault()
+    channel_id = $(e.target).closest('.action').data('id')
+    new App.ControllerGenericNew(
+      pageData:
+        object: __('Email Address')
+      genericObject: 'EmailAddress'
+      container: @el.closest('.content')
+      item:
+        channel_id: channel_id
+      callback: @load
+    )
+
+
+  emailAddressEdit: (e) =>
+    e.preventDefault()
+    id = $(e.target).closest('li').data('id')
+    new App.ControllerGenericEdit(
+      pageData:
+        object: __('Email Address')
+      genericObject: 'EmailAddress'
+      container: @el.closest('.content')
+      id: id
+      callback: @load
+    )
+
+  emailAddressDelete: (e) =>
+    e.preventDefault()
+    id = $(e.target).closest('li').data('id')
+    item = App.EmailAddress.find(id)
+    new App.ControllerGenericDestroyConfirm(
+      item: item
+      container: @el.closest('.content')
+      callback: @load
+    )
+
+  groupChange: (e) =>
+    e.preventDefault()
+    id   = $(e.target).closest('.action').data('id')
+    item = App.Channel.find(id)
+    new ChannelGroupEdit(
+      container: @el.closest('.content')
+      item: item
+      callback: @load
+    )
+
+  reauthenticate: (e) =>
+    e.preventDefault()
+    id                   = $(e.target).closest('.action').data('id')
+    window.location.href = "#{@apiPath}/external_credentials/microsoft_graph/link_account?channel_id=#{id}"
+
+  disable: (e) =>
+    e.preventDefault()
+    id   = $(e.target).closest('.action').data('id')
+    @ajax(
+      id:   'microsoft_graph_disable'
+      type: 'POST'
+      url:  "#{@apiPath}/channels/admin/microsoft_graph/#{id}/disable"
+      processData: true
+      success: =>
+        @load()
+    )
+
+  enable: (e) =>
+    e.preventDefault()
+    id   = $(e.target).closest('.action').data('id')
+    @ajax(
+      id:   'microsoft_graph_enable'
+      type: 'POST'
+      url:  "#{@apiPath}/channels/admin/microsoft_graph/#{id}/enable"
+      processData: true
+      success: =>
+        @load()
+    )
+
+  render: (data) =>
+    # if no microsoft graph app is registered, show intro
+    external_credential = App.ExternalCredential.findByAttribute('name', 'microsoft_graph')
+
+    if !external_credential
+      @html App.view('microsoft_graph/index')()
+      if @channel_id
+        @configApp()
+      return
+
+    channels = []
+
+    for channel_id in data.channel_ids
+      channel = App.Channel.find(channel_id)
+      if channel.group_id
+        channel.group = App.Group.find(channel.group_id)
+      else
+        channel.group = '-'
+
+      email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id })
+      channel.email_addresses = email_addresses
+
+      channels.push channel
+
+    # get all unlinked email addresses
+    not_used_email_addresses = []
+    for email_address_id in data.not_used_email_address_ids
+      not_used_email_addresses.push App.EmailAddress.find(email_address_id)
+
+    @html App.view('microsoft_graph/list')(
+      channels: channels
+      external_credential: external_credential
+      not_used_email_addresses: not_used_email_addresses
+    )
+
+    # On a channel creation we will auto open the edit dialog after the redirect back to zammad to optional
+    # change the inbound configuration.
+    if @channel_id
+      item = App.Channel.find(@channel_id)
+      if item && item.area == 'MicrosoftGraph::Account' && item.options && item.options.backup_imap_classic is undefined && not @error_code
+        @editInbound(undefined, @channel_id, true, true)
+        @channel_id = undefined
+
+    if @error_code is 'AADSTS65004'
+      @error_code = undefined
+      new App.AdminConsentInfo(container: @container, type: 'microsoft_graph')
+
+    if @error_code is 'user_mismatch'
+      @error_code = undefined
+      new App.UserMismatchInfo(container: @container, type: 'microsoft_graph', item: item)
+
+    if @error_code is 'duplicate_email_address'
+      @error_code = undefined
+      new App.DuplicateEmailAddressInfo(container: @container, type: 'microsoft_graph', emailAddress: if @param then decodeURIComponent(@param))
+
+  show: (params) =>
+    for key, value of params
+      if key isnt 'el' && key isnt 'shown' && key isnt 'match'
+        @[key] = value
+
+  configApp: =>
+    new AppConfig(
+      container: @el.parents('.content')
+      callbackUrl: @callbackUrl
+      load: @load
+    )
+
+  editInbound: (e, channel_id, set_active, redirect = false) =>
+    if !channel_id
+      e.preventDefault()
+      channel_id = $(e.target).closest('.action').data('id')
+    item = App.Channel.find(channel_id)
+    new ChannelInboundEdit(
+      container: @el.closest('.content')
+      item: item
+      callback: @load
+      set_active: set_active,
+      redirect: redirect
+    )
+
+class ChannelGroupEdit extends App.ControllerModal
+  buttonClose: true
+  buttonCancel: true
+  buttonSubmit: true
+  head: __('Channel')
+
+  content: =>
+    configureAttributesBase = [
+      { name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
+    ]
+    @form = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributesBase
+        className: ''
+      params: @item
+    )
+    @form.form
+
+  onSubmit: (e) =>
+
+    # get params
+    params = @formParam(e.target)
+
+    # validate form
+    errors = @form.validate(params)
+
+    # show errors in form
+    if errors
+      @log 'error', errors
+      @formValidate(form: e.target, errors: errors)
+      return false
+
+    # disable form
+    @formDisable(e)
+
+    # update
+    @ajax(
+      id:   'channel_email_group'
+      type: 'POST'
+      url:  "#{@apiPath}/channels/admin/microsoft_graph/group/#{@item.id}"
+      data: JSON.stringify(params)
+      processData: true
+      success: (data, status, xhr) =>
+        @callback()
+        @close()
+      error: (xhr) =>
+        data = JSON.parse(xhr.responseText)
+        @formEnable(e)
+        @el.find('.alert--danger').removeClass('hide').text(data.error || __('The changes could not be saved.'))
+    )
+
+class AppConfig extends App.ControllerModal
+  head: __('Connect Microsoft 365 App')
+  shown: true
+  button: __('Connect')
+  buttonCancel: true
+  small: true
+  events:
+    'click .js-copy': 'copyToClipboard'
+
+  content: ->
+    @external_credential = App.ExternalCredential.findByAttribute('name', 'microsoft_graph')
+    content = $(App.view('microsoft_graph/app_config')(
+      external_credential: @external_credential
+      callbackUrl: @callbackUrl
+    ))
+    content.find('.js-select').on('click', (e) =>
+      @selectAll(e)
+    )
+    content
+
+  copyToClipboard: (e) =>
+    e.preventDefault()
+
+    button = $(e.target).parents('[role="button"]')
+    field_name = button.data('targetField')
+    value = $(@container).find("input[name='#{jQuery.escapeSelector(field_name)}']").val()
+
+    @copyToClipboardWithTooltip(value, e.target,'.modal-body', true)
+
+  onClosed: =>
+    return if !@isChanged
+    @isChanged = false
+    @load()
+
+  onSubmit: (e) =>
+    @formDisable(e)
+
+    # verify app credentials
+    @ajax(
+      id:   'microsoft_graph_app_verify'
+      type: 'POST'
+      url:  "#{@apiPath}/external_credentials/microsoft_graph/app_verify"
+      data: JSON.stringify(@formParams())
+      processData: true
+      success: (data, status, xhr) =>
+        if data.attributes
+          if !@external_credential
+            @external_credential = new App.ExternalCredential
+          @external_credential.load(name: 'microsoft_graph', credentials: data.attributes)
+          @external_credential.save(
+            done: =>
+              @isChanged = true
+              @close()
+            fail: =>
+              @el.find('.alert--danger').removeClass('hide').text(__('The entry could not be created.'))
+          )
+          return
+        @formEnable(e)
+        @el.find('.alert--danger').removeClass('hide').text((data && data.error) || __('App could not be verified.'))
+    )
+
+class ChannelInboundNew extends App.ControllerModal
+  buttonClose: true
+  buttonCancel: true
+  buttonSubmit: __('Authenticate')
+  head: __('Channel')
+
+  content: =>
+    configureAttributesBase = [
+      { name: 'mailbox_type',   display: __('Mailbox type'),   tag: 'select', options: { user: __('User mailbox'), shared: __('Shared mailbox') }, translate: true, null: false, value: 'user' },
+      { name: 'shared_mailbox', display: __('Shared mailbox'), tag: 'input', type: 'email', limit: 120, null: true, placeholder: __('user@your-organization.tld'), hide: true },
+    ]
+    @form = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributesBase
+        className: ''
+      handlers: [
+        App.FormHandlerChannelAccountMailboxType.run
+      ]
+    )
+    @form.form
+
+  onSubmit: (e) =>
+    # get params
+    params = @formParam(e.target)
+
+    # validate form
+    errors = @form.validate(params)
+
+    # show errors in form
+    if errors
+      @log 'error', errors
+      @formValidate(form: e.target, errors: errors)
+      return false
+
+    # disable form
+    @formDisable(e)
+
+    query_string = if params.shared_mailbox then "?shared_mailbox=#{encodeURIComponent(params.shared_mailbox)}" else ''
+
+    window.location.href = "#{@apiPath}/external_credentials/microsoft_graph/link_account#{query_string}"
+
+class ChannelInboundEdit extends App.ControllerModal
+  buttonClose: true
+  buttonCancel: true
+  buttonSubmit: __('Save')
+  head: __('Channel')
+
+  constructor: ->
+    super
+    @fetch()
+
+  fetch: =>
+    @startLoading()
+    @ajax(
+      id:   'microsoft_graph_folders'
+      type: 'GET'
+      url:  "#{@apiPath}/channels/admin/microsoft_graph/#{@item.id}/folders"
+      processData: true
+      success: (data, status, xhr) =>
+        @folderOptions = if data.folders then _.reduce(data.folders, @transformFolders, []) else []
+
+        @error = if data.error
+                   message: data.error.message,
+                   hint: @errorCodeLookup(data.error.code)
+
+        @stopLoading()
+        @render()
+      error: (error) =>
+        @stopLoading()
+        @close()
+    )
+
+  transformFolders: (memo, folder) =>
+    children = if _.isArray(folder.childFolders) and folder.childFolders.length then _.reduce(folder.childFolders, @transformFolders, [])
+
+    memo.push({
+      value: folder.id,
+      name: folder.displayName,
+      children: children,
+    })
+
+    memo
+
+  errorCodeLookup: (code) ->
+    switch code
+      when 'MailboxNotEnabledForRESTAPI'
+        __('Did you verify that the user has access to the mailbox? Or consider removing this channel and switch to using a different mailbox type. %l')
+      when 'ErrorItemNotFound'
+        __('Did you confirm that the user has delegation permissions for the mailbox? Or consider removing this channel and switch to using a different mailbox type. %l')
+      when 'ErrorInvalidUser'
+        __('Did you check the validity of the configured mailbox? Or consider removing this channel and switch to using a different mailbox type. %l')
+      else
+        null
+
+  content: =>
+    if @error
+      @buttonSubmit = false
+      return App.view('microsoft_graph/error_message')(error: @error)
+
+    configureAttributesBase = [
+      { name: 'group_id',                 display: __('Destination Group'),       tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
+      { name: 'options::folder_id',       display: __('Folder'),                  tag: 'tree_select', null: true, options: @folderOptions, nulloption: true, default: '', help: __('Specify which folder to fetch from, or leave empty to fetch from ||inbox||.') },
+      { name: 'options::keep_on_server',  display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
+    ]
+    @form = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributesBase
+        className: ''
+      params:
+        group_id: @item.group_id,
+        options:
+          folder_id: @item.options.inbound.options.folder_id,
+          keep_on_server: @item.options.inbound.options.keep_on_server,
+    )
+    @form.form
+
+  onSubmit: (e) =>
+    # get params
+    params = @formParam(e.target)
+
+    # validate form
+    errors = @form.validate(params)
+
+    # show errors in form
+    if errors
+      @log 'error', errors
+      @formValidate(form: e.target, errors: errors)
+      return false
+
+    # disable form
+    @formDisable(e)
+
+    @startLoading()
+
+    if @set_active
+      params['active'] = true
+
+    # update
+    @ajax(
+      id:   'channel_email_inbound'
+      type: 'POST'
+      url:  "#{@apiPath}/channels/admin/microsoft_graph/inbound/#{@item.id}"
+      data: JSON.stringify(params)
+      processData: true
+      success: (data, status, xhr) =>
+        @callback(true)
+        @close()
+      error: (xhr) =>
+        data = JSON.parse(xhr.responseText)
+        @stopLoading()
+        @formEnable(e)
+        @el.find('.alert--danger').removeClass('hide').text(data.error_human || data.error || __('The changes could not be saved.'))
+    )
+
+  onCancel: =>
+    return if not @redirect
+
+    @navigate '#channels/microsoft_graph'
+
+App.Config.set('microsoftGraph', { prio: 5000, name: __('Microsoft 365 Graph Email'), parent: '#channels', target: '#channels/microsoft_graph', controller: App.ChannelMicrosoftGraph, permission: ['admin.channel_microsoft_graph'] }, 'NavBarAdmin')

+ 0 - 1
app/assets/javascripts/app/controllers/_channel/whatsapp.coffee

@@ -87,7 +87,6 @@ class ChannelWhatsapp extends App.ControllerSubContent
       id:   'whatsapp_disable'
       id:   'whatsapp_disable'
       type: 'POST'
       type: 'POST'
       url:  "#{@apiPath}/channels/admin/whatsapp/#{id}/disable"
       url:  "#{@apiPath}/channels/admin/whatsapp/#{id}/disable"
-      data: JSON.stringify(id: id)
       processData: true
       processData: true
       success: =>
       success: =>
         @load()
         @load()

+ 2 - 0
app/assets/javascripts/app/controllers/manage.coffee

@@ -26,6 +26,8 @@ App.Config.set('manage/:target/:page/:search_query', ManageRouter, 'Routes')
 App.Config.set('settings/:target', ManageRouter, 'Routes')
 App.Config.set('settings/:target', ManageRouter, 'Routes')
 App.Config.set('channels/:target', ManageRouter, 'Routes')
 App.Config.set('channels/:target', ManageRouter, 'Routes')
 App.Config.set('channels/:target/error/:error_code', ManageRouter, 'Routes')
 App.Config.set('channels/:target/error/:error_code', ManageRouter, 'Routes')
+App.Config.set('channels/:target/error/:error_code/channel/:channel_id', ManageRouter, 'Routes')
+App.Config.set('channels/:target/error/:error_code/param/:param', ManageRouter, 'Routes')
 App.Config.set('channels/:target/:channel_id', ManageRouter, 'Routes')
 App.Config.set('channels/:target/:channel_id', ManageRouter, 'Routes')
 App.Config.set('system/:target', ManageRouter, 'Routes')
 App.Config.set('system/:target', ManageRouter, 'Routes')
 App.Config.set('system/:target/:integration', ManageRouter, 'Routes')
 App.Config.set('system/:target/:integration', ManageRouter, 'Routes')

+ 14 - 10
app/assets/javascripts/app/models/channel.coffee

@@ -6,16 +6,20 @@ class App.Channel extends App.Model
   displayName: ->
   displayName: ->
     name = ''
     name = ''
     if @options
     if @options
-      if @options.inbound
-        name += "#{@options.inbound.options.user}@#{@options.inbound.options.host} (#{@options.inbound.adapter})"
+      if @options.inbound and @options.inbound.options?.user
+        if @options.inbound.options.host
+          name += "#{@options.inbound.options.user}@#{@options.inbound.options.host} (#{@options.inbound.adapter})"
+        else
+          name += "#{@options.inbound.options.user} (#{@options.inbound.adapter})"
+      else
+        name += "(#{@options.inbound.adapter})"
       if @options.outbound
       if @options.outbound
-        if @options.outbound
-          if name != ''
-            name += ' / '
-          if @options.outbound.options
-            name += "#{@options.outbound.options.host} (#{@options.outbound.adapter})"
-          else
-            name += " (#{@options.outbound.adapter})"
+        if name != ''
+          name += ' / '
+        if @options.outbound.options?.host
+          name += "#{@options.outbound.options.host} (#{@options.outbound.adapter})"
+        else
+          name += "(#{@options.outbound.adapter})"
     if name == ''
     if name == ''
       name = '-'
       name = '-'
-    name
+    name

+ 1 - 1
app/assets/javascripts/app/models/email_address.coffee

@@ -25,7 +25,7 @@ class App.EmailAddress extends App.Model
         if localChannel
         if localChannel
           return channel if channel.area is localChannel.area
           return channel if channel.area is localChannel.area
         else
         else
-          return channel if channel.area is 'Google::Account' || channel.area is 'Microsoft365::Account' || channel.area is 'Email::Account'
+          return channel if channel.area is 'Google::Account' || channel.area is 'Microsoft365::Account' || channel.area is 'MicrosoftGraph::Account' || channel.area is 'Email::Account'
     )
     )
 
 
   @configure_attributes = [
   @configure_attributes = [

Some files were not shown because too many files changed in this diff