Organize directories

This commit is contained in:
Darren Clarke 2023-02-13 13:10:48 +00:00
parent 8a91c9b89b
commit 4898382f78
433 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,256 @@
class Index extends App.ControllerIntegrationBase
featureIntegration: 'pgp_integration'
featureName: 'PGP'
featureConfig: 'pgp_config'
description: [
['PGP (Pretty Good Privacy) is a widely accepted method (or more precisely, a protocol) for sending digitally signed and encrypted messages.']
]
events:
'change .js-switch input': 'switch'
render: =>
super
new Form(
el: @$('.js-form')
)
new App.HttpLog(
el: @$('.js-log')
facility: 'PGP'
)
class Form extends App.Controller
events:
'click .js-addPublicKey': 'addPublicKey'
'click .js-addPrivateKey': 'addPrivateKey'
'click .js-updateGroup': 'updateGroup'
constructor: ->
super
@render()
currentConfig: ->
App.Setting.get('pgp_config')
setConfig: (value) ->
App.Setting.set('pgp_config', value, {notify: true})
render: =>
@config = @currentConfig()
@html App.view('integration/pgp')(
config: @config
)
@keyList()
@groupList()
keyList: =>
new List(el: @$('.js-keyList'))
groupList: =>
new Group(
el: @$('.js-groupList')
config: @config
)
addPublicKey: =>
new PublicKey(
callback: @keyList
)
addPrivateKey: =>
new PrivateKey(
callback: @keyList
)
updateGroup: (e) =>
params = App.ControllerForm.params(e)
@setConfig(params)
class PublicKey extends App.ControllerModal
buttonClose: true
buttonCancel: true
buttonSubmit: 'Add'
autoFocusOnFirstInput: false
head: 'Add Public Key'
large: true
content: ->
# show start dialog
content = $(App.view('integration/pgp_public_key_add')(
head: 'Add Public Key'
))
content
onSubmit: (e) =>
params = new FormData($(e.currentTarget).closest('form').get(0))
params.set('try', true)
if _.isEmpty(params.get('data'))
params.delete('data')
@formDisable(e)
@ajax(
id: 'pgp-public_key-add'
type: 'POST'
url: "#{@apiPath}/integration/pgp/public_key"
processData: false
contentType: false
cache: false
data: params
success: (data, status, xhr) =>
@close()
@callback()
error: (data) =>
@close()
details = data.responseJSON || {}
@notify
type: 'error'
msg: App.i18n.translateContent(details.error_human || details.error || 'The import failed.')
timeout: 6000
)
class PrivateKey extends App.ControllerModal
buttonClose: true
buttonCancel: true
buttonSubmit: 'Add'
autoFocusOnFirstInput: false
head: 'Add Private Key'
large: true
content: ->
# show start dialog
content = $(App.view('integration/pgp_private_key_add')(
head: 'Add Private Key'
))
content
onSubmit: (e) =>
params = new FormData($(e.currentTarget).closest('form').get(0))
params.set('try', true)
if _.isEmpty(params.get('data'))
params.delete('data')
@formDisable(e)
@ajax(
id: 'pgp-private_key-add'
type: 'POST'
url: "#{@apiPath}/integration/pgp/private_key"
processData: false
contentType: false
cache: false
data: params
success: (data, status, xhr) =>
@close()
@callback()
error: (data) =>
@close()
details = data.responseJSON || {}
@notify
type: 'error'
msg: App.i18n.translateContent(details.error_human || details.error || 'The import failed.')
timeout: 6000
)
class List extends App.Controller
events:
'click .js-remove': 'remove'
constructor: ->
super
@load()
load: =>
@ajax(
id: 'pgp-list'
type: 'GET'
url: "#{@apiPath}/integration/pgp/public_key"
success: (data, status, xhr) =>
@render(data)
error: (data, status) =>
# do not close window if request is aborted
return if status is 'abort'
details = data.responseJSON || {}
@notify(
type: 'error'
msg: App.i18n.translateContent(details.error_human || details.error || 'Loading failed.')
)
# do something
)
render: (data) =>
@html App.view('integration/pgp_list')(
keyPairs: data
)
remove: (e) =>
e.preventDefault()
id = $(e.currentTarget).parents('tr').data('id')
return if !id
@ajax(
id: 'pgp-list'
type: 'DELETE'
url: "#{@apiPath}/integration/pgp/public_key"
data: JSON.stringify(id: id)
success: (data, status, xhr) =>
@load()
error: (data, status) =>
# do not close window if request is aborted
return if status is 'abort'
details = data.responseJSON || {}
@notify(
type: 'error'
msg: App.i18n.translateContent(details.error_human || details.error || 'Server operation failed.')
)
)
class Group extends App.Controller
constructor: ->
super
@render()
render: (data) =>
groups = App.Group.search(sortBy: 'name', filter: active: true)
@html App.view('integration/pgp_group')(
groups: groups
)
for group in groups
for type, selector of { default_sign: 'js-signDefault', default_encryption: 'js-encryptionDefault' }
selected = true
if @config?.group_id && @config.group_id[type]
selected = @config.group_id[type][group.id.toString()]
selection = App.UiElement.boolean.render(
name: "group_id::#{type}::#{group.id}"
multiple: false
null: false
nulloption: false
value: selected
class: 'form-control--small'
)
@$("[data-id=#{group.id}] .#{selector}").html(selection)
class State
@current: ->
App.Setting.get('pgp_integration')
App.Config.set(
'Integrationpgp'
{
name: 'PGP'
target: '#system/integration/pgp'
description: 'PGP enables you to send digitally signed and encrypted messages.'
controller: Index
state: State
}
'NavBarIntegrations'
)

View file

@ -0,0 +1,614 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.ticket_perform_action
@defaults: (attribute) ->
defaults = ['ticket.state_id']
groups =
ticket:
name: 'Ticket'
model: 'Ticket'
article:
name: 'Article'
model: 'Article'
if attribute.notification
groups.notification =
name: 'Notification'
model: 'Notification'
# merge config
elements = {}
for groupKey, groupMeta of groups
if !groupMeta.model || !App[groupMeta.model]
if groupKey is 'notification'
elements["#{groupKey}.email"] = { name: 'email', display: 'Email' }
elements["#{groupKey}.sms"] = { name: 'sms', display: 'SMS' }
elements["#{groupKey}.webhook"] = { name: 'webhook', display: 'Webhook' }
else if groupKey is 'article'
elements["#{groupKey}.note"] = { name: 'note', display: 'Note' }
else
for row in App[groupMeta.model].configure_attributes
# ignore passwords and relations
if row.type isnt 'password' && row.name.substr(row.name.length-4,4) isnt '_ids'
# ignore readonly attributes
if !row.readonly
config = _.clone(row)
switch config.tag
when 'datetime'
config.operator = ['static', 'relative']
when 'tag'
config.operator = ['add', 'remove']
elements["#{groupKey}.#{config.name}"] = config
# add ticket deletion action
if attribute.ticket_delete
elements['ticket.action'] =
name: 'action'
display: 'Action'
tag: 'select'
null: false
translate: true
options:
delete: 'Delete'
[defaults, groups, elements]
@placeholder: (elementFull, attribute, params, groups, elements) ->
item = $( App.view('generic/ticket_perform_action/row')( attribute: attribute ) )
selector = @buildAttributeSelector(elementFull, groups, elements)
item.find('.js-attributeSelector').prepend(selector)
item
@render: (attribute, params = {}) ->
[defaults, groups, elements] = @defaults(attribute)
# return item
item = $( App.view('generic/ticket_perform_action/index')( attribute: attribute ) )
# add filter
item.on('click', '.js-rowActions .js-add', (e) =>
element = $(e.target).closest('.js-filterElement')
placeholder = @placeholder(item, attribute, params, groups, elements)
if element.get(0)
element.after(placeholder)
else
item.append(placeholder)
placeholder.find('.js-attributeSelector select').trigger('change')
@updateAttributeSelectors(item)
)
# remove filter
item.on('click', '.js-rowActions .js-remove', (e) =>
return if $(e.currentTarget).hasClass('is-disabled')
$(e.target).closest('.js-filterElement').remove()
@updateAttributeSelectors(item)
)
# change attribute selector
item.on('change', '.js-attributeSelector select', (e) =>
elementRow = $(e.target).closest('.js-filterElement')
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
@rebuildAttributeSelectors(item, elementRow, groupAndAttribute, elements, {}, attribute)
@updateAttributeSelectors(item)
)
# change operator selector
item.on('change', '.js-operator select', (e) =>
elementRow = $(e.target).closest('.js-filterElement')
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
@buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute)
)
# build initial params
if _.isEmpty(params[attribute.name])
for groupAndAttribute in defaults
# build and append
element = @placeholder(item, attribute, params, groups, elements)
item.append(element)
@rebuildAttributeSelectors(item, element, groupAndAttribute, elements, {}, attribute)
else
for groupAndAttribute, meta of params[attribute.name]
# build and append
element = @placeholder(item, attribute, params, groups, elements)
@rebuildAttributeSelectors(item, element, groupAndAttribute, elements, meta, attribute)
item.append(element)
@disableRemoveForOneAttribute(item)
item
@buildAttributeSelector: (elementFull, groups, elements) ->
# find first possible attribute
selectedValue = ''
elementFull.find('.js-attributeSelector select option').each(->
if !selectedValue && !$(@).prop('disabled')
selectedValue = $(@).val()
)
selection = $('<select class="form-control"></select>')
for groupKey, groupMeta of groups
displayName = App.i18n.translateInline(groupMeta.name)
selection.closest('select').append("<optgroup label=\"#{displayName}\" class=\"js-#{groupKey}\"></optgroup>")
optgroup = selection.find("optgroup.js-#{groupKey}")
for elementKey, elementGroup of elements
spacer = elementKey.split(/\./)
if spacer[0] is groupKey
attributeConfig = elements[elementKey]
displayName = App.i18n.translateInline(attributeConfig.display)
selected = ''
if elementKey is selectedValue
selected = 'selected="selected"'
optgroup.append("<option value=\"#{elementKey}\" #{selected}>#{displayName}</option>")
selection
# disable - if we only have one attribute
@disableRemoveForOneAttribute: (elementFull) ->
if elementFull.find('.js-attributeSelector select').length > 1
elementFull.find('.js-remove').removeClass('is-disabled')
else
elementFull.find('.js-remove').addClass('is-disabled')
@updateAttributeSelectors: (elementFull) ->
# enable all
elementFull.find('.js-attributeSelector select option').prop('disabled', false)
# disable all used attributes
elementFull.find('.js-attributeSelector select').each(->
keyLocal = $(@).val()
elementFull.find('.js-attributeSelector select option[value="' + keyLocal + '"]').attr('disabled', true)
)
# disable - if we only have one attribute
@disableRemoveForOneAttribute(elementFull)
@rebuildAttributeSelectors: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
# set attribute
if groupAndAttribute
elementRow.find('.js-attributeSelector select').val(groupAndAttribute)
notificationTypeMatch = groupAndAttribute.match(/^notification.([\w]+)$/)
articleTypeMatch = groupAndAttribute.match(/^article.([\w]+)$/)
if _.isArray(notificationTypeMatch) && notificationType = notificationTypeMatch[1]
elementRow.find('.js-setAttribute').html('').addClass('hide')
elementRow.find('.js-setArticle').html('').addClass('hide')
@buildNotificationArea(notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
else if _.isArray(articleTypeMatch) && articleType = articleTypeMatch[1]
elementRow.find('.js-setAttribute').html('').addClass('hide')
elementRow.find('.js-setNotification').html('').addClass('hide')
@buildArticleArea(articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
else
elementRow.find('.js-setNotification').html('').addClass('hide')
elementRow.find('.js-setArticle').html('').addClass('hide')
if !elementRow.find('.js-setAttribute div').get(0)
attributeSelectorElement = $( App.view('generic/ticket_perform_action/attribute_selector')(
attribute: attribute
name: name
meta: meta || {}
))
elementRow.find('.js-setAttribute').html(attributeSelectorElement).removeClass('hide')
@buildOperator(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
@buildOperator: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
if !meta.operator
meta.operator = currentOperator
name = "#{attribute.name}::#{groupAndAttribute}::operator"
selection = $("<select class=\"form-control\" name=\"#{name}\"></select>")
attributeConfig = elements[groupAndAttribute]
if !attributeConfig || !attributeConfig.operator
elementRow.find('.js-operator').parent().addClass('hide')
else
elementRow.find('.js-operator').parent().removeClass('hide')
if attributeConfig && attributeConfig.operator
for operator in attributeConfig.operator
operatorName = App.i18n.translateInline(operator)
selected = ''
if meta.operator is operator
selected = 'selected="selected"'
selection.append("<option value=\"#{operator}\" #{selected}>#{operatorName}</option>")
selection
elementRow.find('.js-operator select').replaceWith(selection)
@buildPreCondition(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
@buildPreCondition: (elementFull, elementRow, groupAndAttribute, elements, meta, attributeConfig) ->
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
currentPreCondition = elementRow.find('.js-preCondition option:selected').attr('value')
if !meta.pre_condition
meta.pre_condition = currentPreCondition
toggleValue = =>
preCondition = elementRow.find('.js-preCondition option:selected').attr('value')
if preCondition isnt 'specific'
elementRow.find('.js-value select').html('')
elementRow.find('.js-value').addClass('hide')
else
elementRow.find('.js-value').removeClass('hide')
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
# force to use auto complition on user lookup
attribute = _.clone(attributeConfig)
name = "#{attribute.name}::#{groupAndAttribute}::value"
attributeSelected = elements[groupAndAttribute]
preCondition = false
if attributeSelected.relation is 'User'
preCondition = 'user'
attribute.tag = 'user_autocompletion'
if attributeSelected.relation is 'Organization'
preCondition = 'org'
attribute.tag = 'autocompletion_ajax'
if !preCondition
elementRow.find('.js-preCondition select').html('')
elementRow.find('.js-preCondition').closest('.controls').addClass('hide')
toggleValue()
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
return
elementRow.find('.js-preCondition').closest('.controls').removeClass('hide')
name = "#{attribute.name}::#{groupAndAttribute}::pre_condition"
selection = $("<select class=\"form-control\" name=\"#{name}\" ></select>")
options = {}
if preCondition is 'user'
options =
'current_user.id': App.i18n.translateInline('current user')
'specific': App.i18n.translateInline('specific user')
if attributeSelected.null is true
options['not_set'] = App.i18n.translateInline('unassign user')
else if preCondition is 'org'
options =
'current_user.organization_id': App.i18n.translateInline('current user organization')
'specific': App.i18n.translateInline('specific organization')
for key, value of options
selected = ''
if key is meta.pre_condition
selected = 'selected="selected"'
selection.append("<option value=\"#{key}\" #{selected}>#{App.i18n.translateInline(value)}</option>")
elementRow.find('.js-preCondition').closest('.controls').removeClass('hide')
elementRow.find('.js-preCondition select').replaceWith(selection)
elementRow.find('.js-preCondition select').on('change', (e) ->
toggleValue()
)
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
toggleValue()
@buildValue: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
name = "#{attribute.name}::#{groupAndAttribute}::value"
# build new item
attributeConfig = elements[groupAndAttribute]
config = _.clone(attributeConfig)
if config.relation is 'User'
config.tag = 'user_autocompletion'
if config.relation is 'Organization'
config.tag = 'autocompletion_ajax'
# render ui element
item = ''
if config && App.UiElement[config.tag]
config['name'] = name
if attribute.value && attribute.value[groupAndAttribute]
config['value'] = _.clone(attribute.value[groupAndAttribute]['value'])
config.multiple = false
config.nulloption = config.null
if config.tag is 'checkbox'
config.tag = 'select'
tagSearch = "#{config.tag}_search"
if config.tag is 'datetime'
config.validationContainer = 'self'
if App.UiElement[tagSearch]
item = App.UiElement[tagSearch].render(config, {})
else
item = App.UiElement[config.tag].render(config, {})
relative_operators = [
'before (relative)',
'within next (relative)',
'within last (relative)',
'after (relative)',
'till (relative)',
'from (relative)',
'relative'
]
upcoming_operator = meta.operator
if !_.include(config.operator, upcoming_operator)
if Array.isArray(config.operator)
upcoming_operator = config.operator[0]
else
upcoming_operator = null
if _.include(relative_operators, upcoming_operator)
config['name'] = "#{attribute.name}::#{groupAndAttribute}"
if attribute.value && attribute.value[groupAndAttribute]
config['value'] = _.clone(attribute.value[groupAndAttribute])
item = App.UiElement['time_range'].render(config, {})
elementRow.find('.js-setAttribute > .flex > .js-value').removeClass('hide').html(item)
@buildNotificationArea: (notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
return if elementRow.find(".js-setNotification .js-body-#{notificationType}").get(0)
elementRow.find('.js-setNotification').empty()
options =
'article_last_sender': 'Sender of last article'
'ticket_owner': 'Owner'
'ticket_customer': 'Customer'
'ticket_agents': 'All agents'
name = "#{attribute.name}::notification.#{notificationType}"
messageLength = switch notificationType
when 'sms' then 160
else 200000
# meta.recipient was a string in the past (single-select) so we convert it to array if needed
if !_.isArray(meta.recipient)
meta.recipient = [meta.recipient]
columnSelectOptions = []
for key, value of options
selected = undefined
for recipient in meta.recipient
if key is recipient
selected = true
columnSelectOptions.push({ value: key, name: App.i18n.translatePlain(value), selected: selected })
columnSelectRecipientUserOptions = []
for user in App.User.all()
key = "userid_#{user.id}"
selected = undefined
for recipient in meta.recipient
if key is recipient
selected = true
columnSelectRecipientUserOptions.push({ value: key, name: "#{user.firstname} #{user.lastname}", selected: selected })
columnSelectRecipient = new App.ColumnSelect
attribute:
name: "#{name}::recipient"
options: [
{
label: 'Variables',
group: columnSelectOptions
},
{
label: 'User',
group: columnSelectRecipientUserOptions
},
]
selectionRecipient = columnSelectRecipient.element()
if notificationType is 'webhook'
notificationElement = $( App.view('generic/ticket_perform_action/webhook')(
attribute: attribute
name: name
notificationType: notificationType
meta: meta || {}
))
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
if App.Webhook.search(filter: { active: true }).length isnt 0 || !_.isEmpty(meta.webhook_id)
webhookSelection = App.UiElement.select.render(
name: "#{name}::webhook_id"
multiple: false
null: false
relation: 'Webhook'
value: meta.webhook_id
translate: false
nulloption: true
)
else
webhookSelection = App.view('generic/ticket_perform_action/webhook_not_available')( attribute: attribute )
notificationElement.find('.js-webhooks').html(webhookSelection)
else
notificationElement = $( App.view('generic/ticket_perform_action/notification')(
attribute: attribute
name: name
notificationType: notificationType
meta: meta || {}
))
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
visibilitySelection = App.UiElement.select.render(
name: "#{name}::internal"
multiple: false
null: false
options: { true: 'internal', false: 'public' }
value: meta.internal || 'false'
translate: true
)
includeAttachmentsCheckbox = App.UiElement.select.render(
name: "#{name}::include_attachments"
multiple: false
null: false
options: { true: 'Yes', false: 'No' }
value: meta.include_attachments || 'false'
translate: true
)
notificationElement.find('.js-internal').html(visibilitySelection)
notificationElement.find('.js-include_attachments').html(includeAttachmentsCheckbox)
notificationElement.find('.js-body div[contenteditable="true"]').ce(
mode: 'richtext'
placeholder: 'message'
maxlength: messageLength
)
new App.WidgetPlaceholder(
el: notificationElement.find('.js-body div[contenteditable="true"]').parent()
objects: [
{
prefix: 'ticket'
object: 'Ticket'
display: 'Ticket'
},
{
prefix: 'article'
object: 'TicketArticle'
display: 'Article'
},
{
prefix: 'user'
object: 'User'
display: 'Current User'
},
]
)
elementRow.find('.js-setNotification').html(notificationElement).removeClass('hide')
if App.Config.get('smime_integration') == true || App.Config.get('pgp_integration') == true
selection = App.UiElement.select.render(
name: "#{name}::sign"
multiple: false
options: {
'no': 'Do not sign email'
'discard': 'Sign email (if not possible, discard notification)'
'always': 'Sign email (if not possible, send notification anyway)'
}
value: meta.sign
translate: true
)
elementRow.find('.js-sign').html(selection)
selection = App.UiElement.select.render(
name: "#{name}::encryption"
multiple: false
options: {
'no': 'Do not encrypt email'
'discard': 'Encrypt email (if not possible, discard notification)'
'always': 'Encrypt email (if not possible, send notification anyway)'
}
value: meta.encryption
translate: true
)
elementRow.find('.js-encryption').html(selection)
@buildArticleArea: (articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
return if elementRow.find(".js-setArticle .js-body-#{articleType}").get(0)
elementRow.find('.js-setArticle').empty()
name = "#{attribute.name}::article.#{articleType}"
selection = App.UiElement.select.render(
name: "#{name}::internal"
multiple: false
null: false
label: 'Visibility'
options: { true: 'internal', false: 'public' }
value: meta.internal
translate: true
)
articleElement = $( App.view('generic/ticket_perform_action/article')(
attribute: attribute
name: name
articleType: articleType
meta: meta || {}
))
articleElement.find('.js-internal').html(selection)
articleElement.find('.js-body div[contenteditable="true"]').ce(
mode: 'richtext'
placeholder: 'message'
maxlength: 200000
)
new App.WidgetPlaceholder(
el: articleElement.find('.js-body div[contenteditable="true"]').parent()
objects: [
{
prefix: 'ticket'
object: 'Ticket'
display: 'Ticket'
},
{
prefix: 'article'
object: 'TicketArticle'
display: 'Article'
},
{
prefix: 'user'
object: 'User'
display: 'Current User'
},
]
)
elementRow.find('.js-setArticle').html(articleElement).removeClass('hide')
@humanText: (condition) ->
none = App.i18n.translateContent('No filter.')
return [none] if _.isEmpty(condition)
[defaults, groups, operators, elements] = @defaults()
rules = []
for attribute, value of condition
objectAttribute = attribute.split(/\./)
# get stored params
if meta && objectAttribute[1]
model = toCamelCase(objectAttribute[0])
config = elements[attribute]
valueHuman = []
if _.isArray(value)
for data in value
r = @humanTextLookup(config, data)
valueHuman.push r
else
valueHuman.push @humanTextLookup(config, value)
if valueHuman.join
valueHuman = valueHuman.join(', ')
rules.push "#{App.i18n.translateContent('Set')} <b>#{App.i18n.translateContent(model)} -> #{App.i18n.translateContent(config.display)}</b> #{App.i18n.translateContent('to')} <b>#{valueHuman}</b>."
return [none] if _.isEmpty(rules)
rules
@humanTextLookup: (config, value) ->
return value if !App[config.relation]
return value if !App[config.relation].exists(value)
data = App[config.relation].fullLocal(value)
return value if !data
if data.displayName
return App.i18n.translateContent( data.displayName() )
valueHuman.push App.i18n.translateContent( data.name )

View file

@ -0,0 +1,99 @@
# Methods for displaying security ui elements and to get security params
App.SecurityOptions =
securityOptionsShow: ->
@$('.js-securityOptions').removeClass('hide')
securityOptionsHide: ->
@$('.js-securityOptions').addClass('hide')
securityOptionsShown: ->
!@$('.js-securityOptions').hasClass('hide')
securityEnabled: ->
App.Config.get('smime_integration') || App.Config.get('pgp_integration')
paramsSecurity: =>
if @$('.js-securityOptions').hasClass('hide')
return {}
security = {}
security.encryption ||= {}
security.sign ||= {}
if App.Config.get('pgp_integration')
security.type = 'PGP'
else
security.type = 'S/MIME'
if @$('.js-securityEncrypt').hasClass('btn--active')
security.encryption.success = true
if @$('.js-securitySign').hasClass('btn--active')
security.sign.success = true
security
updateSecurityOptionsRemote: (key, ticket, article, securityOptions) ->
if securityOptions.type == 'PGP'
id = "pgp-check-#{key}"
url = "#{@apiPath}/integration/pgp"
securityConfig = App.Config.get('pgp_config')
else
id = "smime-check-#{key}"
url = "#{@apiPath}/integration/smime"
securityConfig = App.Config.get('smime_config')
callback = =>
@ajax(
id: id
type: 'POST'
url: url
data: JSON.stringify(ticket: ticket, article: article)
processData: true
success: (data, status, xhr) =>
# get default selected security options
selected =
encryption: true
sign: true
for type, selector of { default_sign: 'sign', default_encryption: 'encryption' }
if securityConfig?.group_id?[type] && ticket.group_id
if securityConfig.group_id[type][ticket.group_id.toString()] == false
selected[selector] = false
@$('.js-securityEncryptComment').attr('title', data.encryption.comment)
# if encryption is possible
if data.encryption.success is true
@$('.js-securityEncrypt').attr('disabled', false)
# overrule current selection with Group configuration
if selected.encryption
@$('.js-securityEncrypt').addClass('btn--active')
else
@$('.js-securityEncrypt').removeClass('btn--active')
# if encryption is not possible
else
@$('.js-securityEncrypt').attr('disabled', true)
@$('.js-securityEncrypt').removeClass('btn--active')
@$('.js-securitySignComment').attr('title', data.sign.comment)
# if sign is possible
if data.sign.success is true
@$('.js-securitySign').attr('disabled', false)
# overrule current selection with Group configuration
if selected.sign
@$('.js-securitySign').addClass('btn--active')
else
@$('.js-securitySign').removeClass('btn--active')
# if sign is possible
else
@$('.js-securitySign').attr('disabled', true)
@$('.js-securitySign').removeClass('btn--active')
error: (data) ->
details = data.responseJSON || {}
console.log(details)
)
@delay(callback, 200, 'security-check')

View file

@ -0,0 +1,14 @@
<form>
<h2><%- @T('Public & Private Keys') %></h2>
<div class="settings-entry settings-entry--stretched js-keyList"></div>
<div class="btn btn--primary js-addPublicKey"><%- @T('Add Public Key') %></div>
<div class="btn js-addPrivateKey"><%- @T('Add Private Key') %></div>
<hr>
<h2><%- @T('Default Behavior') %></h2>
<p>Choose the default behavior of the PGP integration on per group basis. If signing or encrypting is not possible, the setting has no effect. Agents call always manually alter the behavior for each article.</p>
<div class="settings-entry settings-entry--stretched js-groupList"></div>
<div class="btn btn--primary js-updateGroup"><%- @T('Update') %></div>
</form>

View file

@ -0,0 +1,25 @@
<table class="settings-list">
<thead>
<tr>
<th width="55%"><%- @T('Group') %>
<th><%- @T('Sign') %>
<th><%- @T('Encryption') %>
</tr>
</thead>
<tbody>
<% if _.isEmpty(@groups): %>
<tr>
<td colspan="6">
<%- @T('No Entries') %>
</td>
</tr>
<% else: %>
<% for group in @groups: %>
<tr data-id="<%= group.id %>">
<td><%= group.name %>
<td class="js-signDefault">
<td class="js-encryptionDefault">
<% end %>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,44 @@
<table class="settings-list settings-list--stretch">
<thead>
<tr>
<th width="35%"><%- @T('Email') %>
<th width="60%"><%- @T('Fingerprint') %>
<th width="5%"><%- @T('Actions') %>
</thead>
<tbody>
<% if _.isEmpty(@keyPairs): %>
<tr>
<td colspan="6">
<%- @T('No Entries') %>
</td>
</tr>
<% else: %>
<% for keyPair in @keyPairs: %>
<tr data-id="<%= keyPair.id %>">
<td><% if !_.isEmpty(keyPair.email_addresses): %><%= keyPair.email_addresses.toString() %><% end %>
<% if keyPair.private_key: %><br><i><%- @T('Including private key.') %></i><% end %>
<td title="<%= keyPair.fingerprint %>"><%= keyPair.fingerprint %>
<td>
<div class="dropdown dropdown--actions">
<div class="btn btn--table btn--text btn--secondary js-action" data-toggle="dropdown">
<%- @Icon('overflow-button') %>
</div>
<ul class="dropdown-menu dropdown-menu-right js-table-action-menu" role="menu">
<% if keyPair.private_key: %>
<li role="presentation" data-table-action="download-private">
<a href="<%= @C('http_type') %>://<%= @C('fqdn')%>/api/v1/integration/pgp/private_key_download/<%= keyPair.id %>" download><%- @Icon('download') %> <%- @T('Download Private Key') %></a>
</li>
<% end %>
<li role="presentation" data-table-action="download-public">
<a href="<%= @C('http_type') %>://<%= @C('fqdn')%>/api/v1/integration/pgp/public_key_download/<%= keyPair.id %>"%download><%- @Icon('download') %> <%- @T('Download Public Key') %></a>
</li>
<li role="presentation" class="danger js-remove" data-table-action="remove">
<%- @Icon('trash') %> <%- @T('Delete') %>
</li>
</ul>
</div>
</td>
<% end %>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,38 @@
<div>
<p class="alert alert--danger js-error hide"></p>
<div class="form-field-group">
<div class="form-group">
<div class="formGroup-label">
<label for="private_key-upload"><%- @T('Upload Private Key') %></label>
</div>
<div class="controls">
<input name="file" type="file" id="private_key-upload">
</div>
</div>
<div class="or-divider">
<span><%- @T('or') %></span>
</div>
<div class="form-group">
<div class="formGroup-label">
<label for="private_key-paste"><%- @T('Paste Private Key') %></label>
</div>
<div class="controls">
<textarea cols="25" rows="20" name="data" style="height: 200px;"
id="private_key-paste"></textarea>
</div>
</div>
</div>
<div class="form-group">
<div class="formGroup-label">
<label for="private_key-secret"><%- @T('Enter Private Key Secret') %></label>
</div>
<div class="controls">
<input class="form-control" name="secret" type="password" id="private_key-secret">
</div>
</div>
</div>

View file

@ -0,0 +1,27 @@
<div>
<p class="alert alert--danger js-error hide"></p>
<div class="form-group">
<div class="formGroup-label">
<label for="public_key-upload"><%- @T('Upload Public Key') %></label>
</div>
<div class="controls">
<input name="file" type="file" id="public_key-upload">
</div>
</div>
<div class="or-divider">
<span><%- @T('or') %></span>
</div>
<div class="form-group">
<div class="formGroup-label">
<label for="public_key-paste"><%- @T('Paste Public Key') %></label>
</div>
<div class="controls">
<textarea cols="25" rows="20" name="data" style="height: 200px;"
id="public_key-paste"></textarea>
</div>
</div>
</div>

View file

@ -0,0 +1 @@
.icon-pgp { width:17px; height: 17px; }

View file

@ -0,0 +1,154 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Integration::PGPController < ApplicationController
prepend_before_action { authentication_check && authorize! }
def public_key_download
cert = PGPKeypair.find(params[:id])
send_data(
cert.public_key,
filename: "#{cert.fingerprint}.asc",
type: 'text/plain',
disposition: 'attachment'
)
end
def private_key_download
cert = PGPKeypair.find(params[:id])
send_data(
cert.private_key,
filename: "#{cert.fingerprint}-private.asc",
type: 'text/plain',
disposition: 'attachment'
)
end
def public_key_list
render json: PGPKeypair.all, methods: :email_addresses
end
def public_key_delete
PGPKeypair.find(params[:id]).destroy!
render json: {
result: 'ok'
}
end
def public_key_add
string = params[:data]
string = params[:file].read.force_encoding('utf-8') if string.blank? && params[:file].present?
items = PGPKeypair.create_public_keys(string)
render json: {
result: 'ok',
response: items
}
rescue StandardError => e
unprocessable_entity(e)
end
def private_key_delete
PGPKeypair.find(params[:id]).update!(
private_key: nil,
private_key_secret: nil
)
render json: {
result: 'ok'
}
end
def private_key_add
string = params[:data]
string = params[:file].read.force_encoding('utf-8') if string.blank? && params[:file].present?
raise "Parameter 'data' or 'file' required." if string.blank?
PGPKeypair.create_private_keys(string, params[:secret])
render json: {
result: 'ok'
}
rescue StandardError => e
unprocessable_entity(e)
end
def search
result = {
type: 'PGP'
}
result[:encryption] = article_encryption(params[:article])
result[:sign] = article_sign(params[:ticket])
render json: result
end
def article_encryption(article)
result = {
success: false,
comment: 'no recipient found'
}
return result if article.blank?
return result if article[:to].blank? && article[:cc].blank?
recipient = [article[:to], article[:cc]].compact.join(',').to_s
recipients = []
begin
list = Mail::AddressList.new(recipient)
list.addresses.each do |address|
recipients.push address.address
end
rescue StandardError # rubocop:disable Lint/SuppressedException
end
return result if recipients.blank?
begin
keys = PGPKeypair.for_recipient_email_addresses!(recipients)
if keys
result[:success] = true
result[:comment] = "keys found for #{recipients.join(',')}"
end
rescue StandardError => e
result[:comment] = e.message
end
result
end
def article_sign(ticket)
result = {
success: false,
comment: 'key not found'
}
return result if ticket.blank? || !ticket[:group_id]
group = Group.find_by(id: ticket[:group_id])
return result unless group
email_address = group.email_address
begin
list = Mail::AddressList.new(email_address.email)
from = list.addresses.first.to_s
key = PGPKeypair.for_sender_email_address(from)
if key
result[:success] = true
result[:comment] = "key for #{email_address.email} found"
else
result[:success] = false
result[:comment] = "no key for #{email_address.email} found"
end
rescue StandardError => e
result[:comment] = e.message
end
result
end
end

View file

@ -0,0 +1,64 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class PGPKeypair < ApplicationModel
validates :fingerprint, uniqueness: { case_sensitive: true }
def self.create_private_keys(raw, secret)
Sequoia.emails_of(keys: raw).each do |address|
downcased_address = address.downcase
public_key = find_each.detect do |certificate|
certificate.email_addresses.include?(downcased_address)
end
unless public_key
raise Exceptions::UnprocessableEntity,
'The public key for this private key could not be found.'
end
public_key.update!(private_key: raw, private_key_secret: secret)
end
end
def self.create_public_keys(raw)
create!(public_key: raw)
end
def self.for_sender_email_address(address)
downcased_address = address.downcase
where.not(private_key: nil).find_each.detect do |certificate|
certificate.email_addresses.include?(downcased_address)
end
end
def self.for_recipient_email_addresses!(addresses)
certificates = []
remaining_addresses = addresses.map(&:downcase)
find_each do |certificate|
# intersection of both lists
certificate_for = certificate.email_addresses & remaining_addresses
next if certificate_for.blank?
certificates.push(certificate)
# subtract found recipient(s)
remaining_addresses -= certificate_for
# end loop if no addresses are remaining
break if remaining_addresses.blank?
end
return certificates if remaining_addresses.blank?
raise ActiveRecord::RecordNotFound,
"Can't find PGP encryption certificates for: #{remaining_addresses.join(', ')}"
end
def public_key=(string)
self.fingerprint = Sequoia.fingerprints_of(keys: string).first
self[:public_key] = string
end
def email_addresses
@email_addresses ||= Sequoia.emails_of(keys: public_key)
end
end

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Controllers::Integration::PGPControllerPolicy < Controllers::ApplicationControllerPolicy
permit! :search, to: 'ticket.agent'
default_permit!('admin.integration.pgp')
end

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
# Be sure to restart your server when you modify this file.
# Add new inflection rules using the following format. Inflections
# are locale specific, and you may define rules for as many different
# locales as you wish. All of these examples are active by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.plural /^(ox)$/i, '\1en'
# inflect.singular /^(ox)en/i, '\1'
# inflect.irregular 'person', 'people'
# inflect.uncountable %w( fish sheep )
# end
# These inflection rules are supported but not enabled by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym 'RESTful'
# end
ActiveSupport::Inflector.inflections(:en) do |inflect|
# Rails thinks the singularized version of knowledge_bases is knowledge_basis?!
# see: KnowledgeBase.table_name.singularize
inflect.irregular 'base', 'bases'
inflect.acronym 'SMIME'
inflect.acronym 'PGP'
inflect.acronym 'GitLab'
inflect.acronym 'GitHub'
end

View file

@ -0,0 +1,18 @@
require 'ruby_openpgp'
Rails.application.config.before_configuration do
#FIXME need icon
icon = File.read("public/assets/images/icons/pgp.svg")
doc = File.open("public/assets/images/icons.svg") { |f| Nokogiri::XML(f) }
if !doc.at_css('#icon-pgp')
doc.at('svg').add_child(icon)
Rails.logger.debug "PGP support icon added to icon set"
else
Rails.logger.debug "PGP support icon already in icon set"
end
File.write("public/assets/images/icons.svg", doc.to_xml)
end
# Rails.application.config.after_initialize do
# Ticket::Article.add_observer Observer::Ticket::Article::CommunicatePgpSupport.instance
# end

View file

@ -0,0 +1,14 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
match api_path + '/integration/pgp', to: 'integration/pgp#search', via: :post
match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_add', via: :post
match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_delete', via: :delete
match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_list', via: :get
match api_path + '/integration/pgp/private_key', to: 'integration/pgp#private_key_add', via: :post
match api_path + '/integration/pgp/private_key', to: 'integration/pgp#private_key_delete', via: :delete
match api_path + '/integration/pgp/public_key_download/:id', to: 'integration/pgp#public_key_download', via: :get
match api_path + '/integration/pgp/private_key_download/:id', to: 'integration/pgp#private_key_download', via: :get
end

View file

@ -0,0 +1,65 @@
class PGPSupport < ActiveRecord::Migration[5.2]
def up
# return if it's a new setup
# return unless Setting.exists?(name: 'system_init_done')
Setting.create_if_not_exists(
title: 'PGP integration',
name: 'pgp_integration',
area: 'Integration::Switch',
description: 'Defines if PGP encryption is enabled or not.',
options: {
form: [
{
display: '',
null: true,
name: 'pgp_integration',
tag: 'boolean',
options: {
true => 'yes',
false => 'no'
}
}
]
},
state: false,
preferences: {
prio: 1,
authentication: true,
permission: ['admin.integration']
},
frontend: true
)
Setting.create_if_not_exists(
title: 'PGP config',
name: 'pgp_config',
area: 'Integration::PGP',
description: 'Defines the PGP config.',
options: {},
state: {},
preferences: {
prio: 2,
permission: ['admin.integration']
},
frontend: true
)
Setting.create_if_not_exists(
title: 'Defines postmaster filter.',
name: '0016_postmaster_filter_smime',
area: 'Postmaster::PreFilter',
description: 'Defines postmaster filter to handle secure mailing.',
options: {},
state: 'Channel::Filter::SecureMailing',
frontend: false
)
create_table :pgp_keypairs do |t|
t.string :fingerprint, limit: 250, null: false
t.binary :public_key, limit: 10.megabytes, null: false
t.binary :private_key, limit: 10.megabytes, null: true
t.string :private_key_secret, limit: 500, null: true
t.timestamps limit: 3, null: false
end
add_index :pgp_keypairs, [:fingerprint], unique: true
end
end

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class SecureMailing::PGP < SecureMailing::Backend
def self.active?
Setting.get('pgp_integration')
end
end

View file

@ -0,0 +1,170 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class SecureMailing::PGP::Incoming < SecureMailing::Backend::Handler
attr_accessor :mail, :content_type
EXPRESSION_ENCRYPTED = %r{application/pgp-encrypted}i.freeze
EXPRESSION_SIGNATURE = %r{application/pgp-signature}i.freeze
def initialize(mail)
super()
@mail = mail
@content_type = mail[:mail_instance].content_type
end
def process
return unless process?
initialize_article_preferences
decrypt
verify_signature
log
end
def initialize_article_preferences
article_preferences[:security] = {
type: 'PGP',
sign: {
success: false,
comment: nil
},
encryption: {
success: false,
comment: nil
}
}
end
def article_preferences
@article_preferences ||= begin
key = :'x-zammad-article-preferences'
mail[key] ||= {}
mail[key]
end
end
def process?
signed? || encrypted?
end
def signed?(check_content_type = content_type)
EXPRESSION_SIGNATURE.match?(check_content_type)
end
def encrypted?(check_content_type = content_type)
EXPRESSION_ENCRYPTED.match?(check_content_type)
end
def decrypt
return unless encrypted?
success = false
comment = 'Private key for decryption could not be found.'
::PGPKeypair.where.not(private_key: [nil, '']).find_each do |cert|
begin
index = mail[:attachments].index { |file| file[:preferences]['Content-Type'] == 'application/pgp-encrypted' }
data = mail[:attachments][index + 1][:data]
decrypted_data = Sequoia.decrypt_for(ciphertext: data.chop, recipient: cert.private_key,
password: cert.private_key_secret)
rescue StandardError
next
end
parse_new_mail(decrypted_data)
success = true
comment = cert.email_addresses.join(', ')
# overwrite content_type for signature checking
@content_type = mail[:mail_instance].content_type
break
end
article_preferences[:security][:encryption] = {
success: success,
comment: comment
}
end
def verify_signature
return unless signed?
success = false
comment = 'Certificate for verification could not be found.'
::PGPKeypair.where.not(public_key: [nil, '']).find_each do |cert|
next unless cert.email_addresses.include? mail[:from_email]
begin
index = mail[:attachments].index { |file| file[:preferences]['Mime-Type'] == 'application/pgp-signature' }
data = mail[:attachments][index][:data]
verified_data = Sequoia.verify_detached_from(plaintext: mail[:mail_instance].body.encoded, signature: data.chop,
sender: cert.public_key)
rescue StandardError
next
end
parse_new_mail(verified_data)
success = true
comment = cert.email_addresses.join(', ')
# overwrite content_type for signature checking
@content_type = mail[:mail_instance].content_type
break
end
article_preferences[:security][:sign] = {
success: success,
comment: comment
}
end
private
def log
%i[sign encryption].each do |action|
result = article_preferences[:security][action]
next if result.blank?
if result[:success]
status = 'success'
elsif result[:comment].blank?
# means not performed
next
else
status = 'failed'
end
HttpLog.create(
direction: 'in',
facility: 'PGP',
url: "#{mail[:from_email]} -> #{mail[:to]}",
status: status,
ip: nil,
request: {
message_id: mail[:message_id]
},
response: article_preferences[:security],
method: action,
created_by_id: 1,
updated_by_id: 1
)
end
end
def parse_new_mail(new_mail)
mail[:mail_instance].header['Content-Type'] = nil
mail[:mail_instance].header['Content-Disposition'] = nil
mail[:mail_instance].header['Content-Transfer-Encoding'] = nil
mail[:mail_instance].header['Content-Description'] = nil
new_raw_mail = "#{mail[:mail_instance].header}#{new_mail}"
mail_new = Channel::EmailParser.new.parse(new_raw_mail)
mail_new.each do |local_key, local_value|
mail[local_key] = local_value
end
end
end

View file

@ -0,0 +1,120 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class SecureMailing::PGP::Outgoing < SecureMailing::Backend::Handler
def initialize(mail, security)
super()
@mail = mail
@security = security
end
def process
return unless process?
if @security[:sign][:success]
sign
log('sign', 'success')
end
if @security[:encryption][:success]
encrypt
log('encryption', 'success')
end
end
def process?
return false if @security.blank?
return false if @security[:type] != 'PGP'
@security[:sign][:success] || @security[:encryption][:success]
end
def cleanup(mail)
part = Mail::Part.new
if mail.multipart?
if mail.content_type =~ /^(multipart[^;]+)/
part.content_type Regexp.last_match(1)
else
part.content_type 'multipart/mixed'
end
mail.body.parts.each do |p|
part.add_part cleanup(p)
end
else
# retain important headers if present
part.content_type mail.content_type
part.content_id mail.header['Content-ID'] if mail.header['Content-ID']
part.content_disposition mail.content_disposition if mail.content_disposition
# force base64 encoding
part.body Mail::Encodings::Base64.encode(mail.body.to_s)
part.body.encoding = 'base64'
end
part
end
def sign
from = @mail.from.first
cert = PGPKeypair.for_sender_email_address(from)
raise "Unable to find PGP private key for '#{from}'" unless cert
signature = Sequoia.sign_detached_with(plaintext: @mail.body.encoded, sender: cert.private_key,
password: cert.private_key_secret)
signature_part = Mail::Part.new do
content_type 'application/pgp-signature; name="signature.asc"'
content_disposition 'attachment; filename="signature.asc"'
content_description 'OpenPGP signature'
body signature
end
@mail.add_part signature_part
@mail.content_type "multipart/signed; protocol=\"application/pgp-signature\"; micalg=\"pgp-sha512\"; boundary=\"#{@mail.boundary}\""
rescue StandardError => e
log('sign', 'failed', e.message)
raise
end
def encrypt
recipients = []
recipients += @mail.to if @mail.to
recipients += @mail.cc if @mail.cc
recipients += @mail.bcc if @mail.bcc
certificates = PGPKeypair.for_recipient_email_addresses!(recipients)
encrypted_control = Mail::Part.new do
content_type 'application/pgp-encrypted'
content_description 'OpenPGP version'
body 'Version: 1'
end
plaintext = @mail.encoded
encrypted_part = Mail::Part.new do
content_type 'application/octet-stream; name="encrypted.asc"'
content_disposition 'inline; filename="encrypted.asc"'
content_description 'OpenPGP encrypted message'
body Sequoia.encrypt_for(plaintext: plaintext, recipients: certificates.map(&:public_key))
end
@mail.body = nil
@mail.add_part encrypted_control
@mail.add_part encrypted_part
@mail.content_type "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"#{@mail.boundary}\""
rescue StandardError => e
log('encryption', 'failed', e.message)
raise
end
def log(action, status, error = nil)
HttpLog.create(
direction: 'out',
facility: 'PGP',
url: "#{@mail[:from_email]} -> #{@mail[:to]}",
status: status,
ip: nil,
request: @security,
response: { error: error },
method: action,
created_by_id: 1,
updated_by_id: 1
)
end
end

View file

@ -0,0 +1,93 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class SecureMailing::PGP::Retry < SecureMailing::Backend::Handler
def initialize(article)
super()
@article = article
end
def process
return existing_result if already_processed?
save_result if retry_succeeded?
retry_result
end
def signature_checked?
@signature_checked ||= existing_result&.dig('sign', 'success') || false
end
def decrypted?
@decrypted ||= existing_result&.dig('encryption', 'success') || false
end
def already_processed?
signature_checked? && decrypted?
end
def existing_result
@article.preferences['security']
end
def mail
@mail ||= begin
raw_mail = @article.as_raw.store_file.content
Channel::EmailParser.new.parse(raw_mail).tap do |parsed|
SecureMailing.incoming(parsed)
end
end
end
def retry_result
@retry_result ||= mail['x-zammad-article-preferences']['security']
end
def signature_found?
return false if signature_checked?
retry_result['sign']['success']
end
def decryption_succeeded?
return false if decrypted?
retry_result['encryption']['success']
end
def retry_succeeded?
return true if signature_found?
decryption_succeeded?
end
def save_result
save_decrypted if decryption_succeeded?
@article.preferences['security'] = retry_result
@article.save!
end
def save_decrypted
@article.content_type = mail['content_type']
@article.body = mail['body']
Store.remove(
object: 'Ticket::Article',
o_id: @article.id
)
mail[:attachments]&.each do |attachment|
filename = attachment[:filename].force_encoding('utf-8')
unless filename.force_encoding('UTF-8').valid_encoding?
filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
end
Store.add(
object: 'Ticket::Article',
o_id: @article.id,
data: attachment[:data],
filename: filename,
preferences: attachment[:preferences],
created_by_id: @article.created_by_id
)
end
end
end

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M512 176a176 176 0 01-208.8 173l-24 27a24 24 0 01-18 8H224v40a24 24 0 01-24 24h-40v40a24 24 0 01-24 24H24a24 24 0 01-24-24v-78a24 24 0 017-17l161.8-161.8A176 176 0 11512 176zm-176-48a48 48 0 1096 0 48 48 0 00-96 0z"/><script/></svg>

After

Width:  |  Height:  |  Size: 304 B