diff --git a/Gemfile b/Gemfile
index 2850c9675..b079a4019 100644
--- a/Gemfile
+++ b/Gemfile
@@ -82,6 +82,7 @@ gem 'omniauth-twitter'
gem 'omniauth-weibo-oauth2'
# channels
+gem 'gmail_xoauth'
gem 'koala'
gem 'telegramAPI'
gem 'twitter', git: 'https://github.com/sferik/twitter.git'
diff --git a/Gemfile.lock b/Gemfile.lock
index bb57c333b..a1c9ebe5e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -214,6 +214,8 @@ GEM
retriable (~> 2.1)
globalid (0.4.2)
activesupport (>= 4.2.0)
+ gmail_xoauth (0.4.2)
+ oauth (>= 0.3.6)
guard (2.15.0)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
@@ -600,6 +602,7 @@ DEPENDENCIES
factory_bot_rails
faker
github_changelog_generator
+ gmail_xoauth
guard
guard-livereload
guard-symlink
diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee
index 32b24b0b4..f85638e9a 100644
--- a/app/assets/javascripts/app/controllers/_application_controller.coffee
+++ b/app/assets/javascripts/app/controllers/_application_controller.coffee
@@ -663,6 +663,14 @@ class App.ControllerModal extends App.Controller
@clearAlerts()
@onSubmit(e)
+ startLoading: =>
+ @$('.modal-body').addClass('hide')
+ @$('.modal-loader').removeClass('hide')
+
+ stopLoading: =>
+ @$('.modal-body').removeClass('hide')
+ @$('.modal-loader').addClass('hide')
+
class App.SessionMessage extends App.ControllerModal
showTrySupport: true
diff --git a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee
index 18eddfca8..471bc42b6 100644
--- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee
+++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee
@@ -381,8 +381,9 @@ class App.ControllerTabs extends App.Controller
events:
'click .nav-tabs [data-toggle="tab"]': 'tabRemember'
- constructor: ->
- super
+ constructor: (params) ->
+ @originParams = params # remember params for sub-controller
+ super(params)
# check authentication
if @requiredPermission
@@ -420,7 +421,7 @@ class App.ControllerTabs extends App.Controller
params.target = tab.target
params.el = @$("##{tab.target}")
@controllerList ||= []
- @controllerList.push new tab.controller(params)
+ @controllerList.push new tab.controller(_.extend(@originParams, params))
# check if tabs need to be show / cant' use .tab(), because tabs are note shown (only one tab exists)
if @tabs.length <= 1
@@ -450,7 +451,7 @@ class App.ControllerNavSidbar extends App.Controller
@bind('ui:rerender',
=>
@render(true)
- @updateNavigation(true)
+ @updateNavigation(true, params)
)
show: (params = {}) =>
@@ -460,7 +461,7 @@ class App.ControllerNavSidbar extends App.Controller
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
- @updateNavigation()
+ @updateNavigation(false, params)
if @activeController && _.isFunction(@activeController.show)
@activeController.show(params)
@@ -482,7 +483,7 @@ class App.ControllerNavSidbar extends App.Controller
selectedItem: selectedItem
)
- updateNavigation: (force) =>
+ updateNavigation: (force, params) =>
groups = @groupsSorted()
selectedItem = @selectedItem(groups)
return if !selectedItem
@@ -491,7 +492,7 @@ class App.ControllerNavSidbar extends App.Controller
@$('.sidebar li').removeClass('active')
@$(".sidebar li a[href=\"#{selectedItem.target}\"]").parent().addClass('active')
- @executeController(selectedItem)
+ @executeController(selectedItem, params)
groupsSorted: =>
@@ -552,16 +553,14 @@ class App.ControllerNavSidbar extends App.Controller
selectedItem
- executeController: (selectedItem) =>
+ executeController: (selectedItem, params) =>
if @activeController
@activeController.el.remove()
@activeController = undefined
@$('.main').append('
')
- @activeController = new selectedItem.controller(
- el: @$('.main div')
- )
+ @activeController = new selectedItem.controller(_.extend(params, el: @$('.main div')))
setPosition: (position) =>
return if @shown
diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee
index 4d7f54740..74809e7e3 100644
--- a/app/assets/javascripts/app/controllers/_channel/email.coffee
+++ b/app/assets/javascripts/app/controllers/_channel/email.coffee
@@ -187,6 +187,7 @@ class App.ChannelEmailAccountOverview extends App.Controller
'click .js-emailAddressEdit': 'emailAddressEdit'
'click .js-emailAddressDelete': 'emailAddressDelete',
'click .js-editNotificationOutbound': 'editNotificationOutbound'
+ 'click .js-migrateGoogleMail': 'migrateGoogleMail'
constructor: ->
super
@@ -379,6 +380,12 @@ class App.ChannelEmailAccountOverview extends App.Controller
channelDriver: @channelDriver
)
+ migrateGoogleMail: (e) =>
+ e.preventDefault()
+ id = $(e.target).closest('.action').data('id')
+ @navigate "#channels/google/#{id}"
+
+
class App.ChannelEmailEdit extends App.ControllerModal
buttonClose: true
buttonCancel: true
diff --git a/app/assets/javascripts/app/controllers/_channel/google.coffee b/app/assets/javascripts/app/controllers/_channel/google.coffee
new file mode 100644
index 000000000..0d47a828c
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/_channel/google.coffee
@@ -0,0 +1,425 @@
+class App.ChannelGoogle extends App.ControllerTabs
+ requiredPermission: 'admin.channel_email'
+ header: 'Google'
+ constructor: ->
+ super
+
+ @title 'Google', true
+
+ @tabs = [
+ {
+ name: 'Accounts',
+ target: 'c-account',
+ controller: ChannelGoogleAccountOverview,
+ },
+ {
+ 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 ChannelGoogleAccountOverview extends App.ControllerSubContent
+ requiredPermission: 'admin.channel_google'
+ events:
+ 'click .js-new': 'new'
+ 'click .js-delete': 'delete'
+ 'click .js-configApp': 'configApp'
+ 'click .js-disable': 'disable'
+ 'click .js-enable': 'enable'
+ 'click .js-channelGroupChange': 'groupChange'
+ 'click .js-editInbound': 'editInbound'
+ 'click .js-rollbackMigration': 'rollbackMigration'
+ 'click .js-emailAddressNew': 'emailAddressNew'
+ 'click .js-emailAddressEdit': 'emailAddressEdit'
+ 'click .js-emailAddressDelete': 'emailAddressDelete',
+
+ constructor: ->
+ super
+
+ #@interval(@load, 60000)
+ @load()
+
+ load: (reset_channel_id = false) =>
+ if reset_channel_id
+ @channel_id = undefined
+ @navigate '#channels/google'
+
+ @startLoading()
+ @ajax(
+ id: 'google_index'
+ type: 'GET'
+ url: "#{@apiPath}/channels_google"
+ processData: true
+ success: (data, status, xhr) =>
+ @stopLoading()
+ App.Collection.loadAssets(data.assets)
+ @callbackUrl = data.callback_url
+ @render(data)
+ )
+
+ render: (data) =>
+
+ # if no google app is registered, show intro
+ external_credential = App.ExternalCredential.findByAttribute('name', 'google')
+ if !external_credential
+ @html App.view('google/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
+
+ # on a channel migration we need to auto redirect
+ # the user to the "Add Account" functionality after
+ # the filled up the external credentials
+ if @channel_id
+ item = App.Channel.find(@channel_id)
+ if item && item.area != 'Google::Account'
+ @new()
+ return
+
+ # 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('google/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, but not for
+ # migrated channel because we guess that the inbound configuration
+ # is already correct for them.
+ if @channel_id
+ item = App.Channel.find(@channel_id)
+ if item && item.area == 'Google::Account' && item.options && item.options.backup_imap_classic is undefined
+ @editInbound(undefined, @channel_id, true)
+ @channel_id = undefined
+
+ 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
+ )
+
+ new: (e) ->
+ window.location.href = "#{@apiPath}/external_credentials/google/link_account"
+
+ delete: (e) =>
+ e.preventDefault()
+ id = $(e.target).closest('.action').data('id')
+ new App.ControllerConfirm(
+ message: 'Sure?'
+ callback: =>
+ @ajax(
+ id: 'google_delete'
+ type: 'DELETE'
+ url: "#{@apiPath}/channels_google"
+ data: JSON.stringify(id: id)
+ processData: true
+ success: =>
+ @load()
+ )
+ container: @el.closest('.content')
+ )
+
+ disable: (e) =>
+ e.preventDefault()
+ id = $(e.target).closest('.action').data('id')
+ @ajax(
+ id: 'google_disable'
+ type: 'POST'
+ url: "#{@apiPath}/channels_google_disable"
+ data: JSON.stringify(id: id)
+ processData: true
+ success: =>
+ @load()
+ )
+
+ enable: (e) =>
+ e.preventDefault()
+ id = $(e.target).closest('.action').data('id')
+ @ajax(
+ id: 'google_enable'
+ type: 'POST'
+ url: "#{@apiPath}/channels_google_enable"
+ data: JSON.stringify(id: id)
+ processData: true
+ success: =>
+ @load()
+ )
+
+ editInbound: (e, channel_id, set_active) =>
+ if !channel_id
+ e.preventDefault()
+ channel_id = $(e.target).closest('.action').data('id')
+ item = App.Channel.find(channel_id)
+ new App.ChannelInboundEdit(
+ container: @el.closest('.content')
+ item: item
+ callback: @load
+ set_active: set_active,
+ )
+
+ rollbackMigration: (e) =>
+ e.preventDefault()
+ id = $(e.target).closest('.action').data('id')
+ @ajax(
+ id: 'google_rollback_migration'
+ type: 'POST'
+ url: "#{@apiPath}/channels_google_rollback_migration"
+ data: JSON.stringify(id: id)
+ processData: true
+ success: =>
+ @load()
+ @notify
+ type: 'success'
+ msg: 'Rollback of channel migration succeeded!'
+ error: (data) =>
+ @notify
+ type: 'error'
+ msg: 'Failed to rollback migration of the channel!'
+ )
+
+ groupChange: (e) =>
+ e.preventDefault()
+ id = $(e.target).closest('.action').data('id')
+ item = App.Channel.find(id)
+ new App.ChannelGroupEdit(
+ container: @el.closest('.content')
+ item: item
+ callback: @load
+ )
+
+ 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
+ )
+
+class App.ChannelInboundEdit extends App.ControllerModal
+ buttonClose: true
+ buttonCancel: true
+ buttonSubmit: true
+ head: 'Channel'
+
+ content: =>
+ configureAttributesBase = [
+ { name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, placeholder: 'optional' },
+ { 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: @item.options.inbound
+ )
+ @form.form
+
+ onSubmit: (e) =>
+ @startLoading()
+
+ # 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)
+
+ if @set_active
+ params['active'] = true
+
+ # update
+ @ajax(
+ id: 'channel_email_inbound'
+ type: 'POST'
+ url: "#{@apiPath}/channels_google_inbound/#{@item.id}"
+ data: JSON.stringify(params)
+ processData: true
+ success: (data, status, xhr) =>
+ @callback(true)
+ @close()
+ error: (xhr) =>
+ @stopLoading()
+ @formEnable(e)
+ details = xhr.responseJSON || {}
+ @notify
+ type: 'error'
+ msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to save changes.')
+ timeout: 6000
+ )
+
+class App.ChannelGroupEdit extends App.ControllerModal
+ buttonClose: true
+ buttonCancel: true
+ buttonSubmit: true
+ head: 'Channel'
+
+ content: =>
+ configureAttributesBase = [
+ { name: 'group_id', display: 'Destination Group', tag: 'select', null: false, relation: 'Group', nulloption: true, 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_google_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').removeClass('hidden').text(data.error || 'Unable to save changes.')
+ )
+
+class AppConfig extends App.ControllerModal
+ head: 'Connect Google App'
+ shown: true
+ button: 'Connect'
+ buttonCancel: true
+ small: true
+
+ content: ->
+ @external_credential = App.ExternalCredential.findByAttribute('name', 'google')
+ content = $(App.view('google/app_config')(
+ external_credential: @external_credential
+ callbackUrl: @callbackUrl
+ ))
+ content.find('.js-select').on('click', (e) =>
+ @selectAll(e)
+ )
+ content
+
+ onClosed: =>
+ return if !@isChanged
+ @isChanged = false
+ @load()
+
+ onSubmit: (e) =>
+ @formDisable(e)
+
+ # verify app credentials
+ @ajax(
+ id: 'google_app_verify'
+ type: 'POST'
+ url: "#{@apiPath}/external_credentials/google/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: 'google', credentials: data.attributes)
+ @external_credential.save(
+ done: =>
+ @isChanged = true
+ @close()
+ fail: =>
+ @el.find('.alert').removeClass('hidden').text('Unable to create entry.')
+ )
+ return
+ @formEnable(e)
+ @el.find('.alert').removeClass('hidden').text(data.error || 'Unable to verify App.')
+ )
+
+App.Config.set('google', { prio: 5000, name: 'Google', parent: '#channels', target: '#channels/google', controller: App.ChannelGoogle, permission: ['admin.channel_google'] }, 'NavBarAdmin')
diff --git a/app/assets/javascripts/app/models/email_address.coffee b/app/assets/javascripts/app/models/email_address.coffee
index c44f949a8..b8f4c2089 100644
--- a/app/assets/javascripts/app/models/email_address.coffee
+++ b/app/assets/javascripts/app/models/email_address.coffee
@@ -3,12 +3,22 @@ class App.EmailAddress extends App.Model
@extend Spine.Model.Ajax
@url: @apiPath + '/email_addresses'
- @filterChannel: (options, type) ->
+ @filterChannel: (options, type, params) ->
return options if type isnt 'collection'
+
+ localChannel = undefined
+ if params && params.channel_id
+ if App.Channel.exists(params.channel_id)
+ localChannel = App.Channel.find(params.channel_id)
+
_.filter(
options
(channel) ->
- return channel if channel && channel.area is 'Email::Account'
+ return if !channel
+ if localChannel
+ return channel if channel.area is localChannel.area
+ else
+ return channel if channel.area is 'Google::Account' || channel.area is 'Email::Account'
)
@configure_attributes = [
diff --git a/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco b/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco
index cdeb70351..5936e5f95 100644
--- a/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco
+++ b/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco
@@ -42,6 +42,21 @@
<% else: %>
<% for channel in @account_channels: %>
+
@@ -140,7 +155,7 @@
<% if channel.active is true: %>
-
<%- @T('Disable') %>
+
<%- @T('Disable') %>
<% else: %>
<%- @T('Enable') %>
<% end %>
diff --git a/app/assets/javascripts/app/views/google/app_config.jst.eco b/app/assets/javascripts/app/views/google/app_config.jst.eco
new file mode 100644
index 000000000..c20621a5d
--- /dev/null
+++ b/app/assets/javascripts/app/views/google/app_config.jst.eco
@@ -0,0 +1,29 @@
+
+
+ <%- @T('The tutorial on how to manage a %s is hosted on our online documentation %l.', 'Google App', 'https://admin-docs.zammad.org/en/latest/channels/google.html') %>
+
+
+ <%- @T('Enter your %s App Keys', 'Google') %>
+
+
+ <%- @T('Your callback URL') %>
+
+
diff --git a/app/assets/javascripts/app/views/google/index.jst.eco b/app/assets/javascripts/app/views/google/index.jst.eco
new file mode 100644
index 000000000..e477e5147
--- /dev/null
+++ b/app/assets/javascripts/app/views/google/index.jst.eco
@@ -0,0 +1,12 @@
+
+
+
+
+
<%- @T('You can connect %s with Zammad. You need to connect your Zammad with %s first.', 'Google Accounts', 'Google') %>
+
<%- @T('Connect Google App') %>
+
+
diff --git a/app/assets/javascripts/app/views/google/list.jst.eco b/app/assets/javascripts/app/views/google/list.jst.eco
new file mode 100644
index 000000000..9cbf392aa
--- /dev/null
+++ b/app/assets/javascripts/app/views/google/list.jst.eco
@@ -0,0 +1,116 @@
+
+
+<% if !_.isEmpty(@not_used_email_addresses): %>
+
+
+
+ <%- @T('Notice') %>: <%- @T('Unassigned email addresses, assign them to a channel or delete them.') %>
+
+
+
+
+<% end %>
+
+
+<% for channel in @channels: %>
+
+
+
+
+
+
+
<%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %>
+
+
+ <% if !_.isEmpty(channel.last_log_out): %>
+
+ <%= channel.last_log_out %>
+
+ <% end %>
+
+
+
+
<%- @T('Email Address') %>
+
+
+ <%- @T('Add') %>
+
+
+
+
+ <% if channel.options.backup_imap_classic: %>
+
<%- @T('Rollback migration') %>
+ <% end %>
+ <% if channel.active is true: %>
+
<%- @T('Disable') %>
+ <% else: %>
+
<%- @T('Enable') %>
+ <% end %>
+
<%- @T('Delete') %>
+
+
+<% end %>
+
diff --git a/app/assets/javascripts/app/views/layout_ref/content.jst.eco b/app/assets/javascripts/app/views/layout_ref/content.jst.eco
index 814567d94..68d05777e 100644
--- a/app/assets/javascripts/app/views/layout_ref/content.jst.eco
+++ b/app/assets/javascripts/app/views/layout_ref/content.jst.eco
@@ -81,6 +81,9 @@
Connection established! .alert--success
Delete Cookies? .alert--warning
System Shutting Down! .alert--danger
+
Alert with button
+
Add all 5 rules to your rule-set.
Add Rules
+
Please migrate this account.
Migrate now!
diff --git a/app/assets/javascripts/app/views/modal.jst.eco b/app/assets/javascripts/app/views/modal.jst.eco
index 2ef7282a9..bf02ba35e 100644
--- a/app/assets/javascripts/app/views/modal.jst.eco
+++ b/app/assets/javascripts/app/views/modal.jst.eco
@@ -17,6 +17,9 @@
<%- @content %>
+