reply.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. import type { TicketById, TicketArticle } from '@shared/entities/ticket/types'
  3. import type { AddressesField } from '@shared/graphql/types'
  4. import type { ConfigList } from '@shared/types/store'
  5. import { uniq } from 'lodash-es'
  6. import type { TicketArticlePerformOptions } from '../types'
  7. import { getArticleSelection, getReplyQuoteHeader } from './selection'
  8. const getEmailAddresses = (field?: Maybe<AddressesField>) => {
  9. if (!field) return []
  10. const addresses = field.parsed?.filter(
  11. (email): email is { emailAddress: string; isSystemAddress: boolean } =>
  12. !!email.emailAddress,
  13. )
  14. if (addresses?.length) {
  15. return addresses
  16. .filter((address) => !address.isSystemAddress)
  17. .map((address) => address.emailAddress)
  18. }
  19. return []
  20. }
  21. const getEmptyArticle = (article: TicketArticle) => ({
  22. articleType: 'email',
  23. subtype: 'reply',
  24. to: [] as string[],
  25. cc: [] as string[],
  26. subject: undefined as string | undefined,
  27. body: '',
  28. inReplyTo: article.messageId,
  29. })
  30. const getPhoneArticle = (ticket: TicketById, article: TicketArticle) => {
  31. const newArticle = getEmptyArticle(article)
  32. const sender = article.sender?.name
  33. // the article we are replying to is an outbound call
  34. if (sender === 'Agent') {
  35. if (article.to?.raw.includes('@')) {
  36. newArticle.to = getEmailAddresses(article.to)
  37. }
  38. // the article we are replying to is an incoming call
  39. } else if (article.from?.raw.includes('@')) {
  40. newArticle.to = getEmailAddresses(article.from)
  41. }
  42. // if sender is customer but in article.from is no email, try to get
  43. // customers email via customer user
  44. if (!newArticle.to.length || newArticle.to.every((r) => !r.includes('@')))
  45. newArticle.to = ticket.customer.email ? [ticket.customer.email] : []
  46. return newArticle
  47. }
  48. const areAddressesSystem = (address?: Maybe<AddressesField>) => {
  49. if (!address?.parsed) return false
  50. return address.parsed.some((address) => address.isSystemAddress)
  51. }
  52. const prepareEmails = (
  53. emailsSeen: Set<string>,
  54. emails: string[],
  55. newEmail?: string[],
  56. ) => {
  57. const filteredEmails = emails
  58. .map((email) => email.toLowerCase())
  59. .filter((email) => {
  60. if (!email || emailsSeen.has(email)) return false
  61. return true
  62. })
  63. if (newEmail) {
  64. filteredEmails.push(...newEmail)
  65. }
  66. filteredEmails.forEach((email) => emailsSeen.add(email))
  67. // see https://github.com/zammad/zammad/issues/2154
  68. return uniq(filteredEmails).map((a) => a.replace(/'(\S+@\S+\.\S+)'/, '$1'))
  69. }
  70. const prepareAllEmails = (
  71. emailsSeen: Set<string>,
  72. article: TicketArticle,
  73. newArticle: ReturnType<typeof getEmptyArticle>,
  74. ) => {
  75. if (article.from) {
  76. newArticle.to = prepareEmails(
  77. emailsSeen,
  78. getEmailAddresses(article.from),
  79. newArticle.to,
  80. )
  81. }
  82. if (article.to) {
  83. newArticle.to = prepareEmails(
  84. emailsSeen,
  85. getEmailAddresses(article.to),
  86. newArticle.to,
  87. )
  88. }
  89. if (article.cc) {
  90. newArticle.cc = prepareEmails(
  91. emailsSeen,
  92. getEmailAddresses(article.cc),
  93. newArticle.cc,
  94. )
  95. }
  96. }
  97. // app/assets/javascripts/app/lib/app_post/utils.coffee:1236
  98. const getRecipientArticle = (
  99. ticket: TicketById,
  100. article: TicketArticle,
  101. all = false,
  102. ) => {
  103. const type = article.type?.name
  104. if (type === 'phone') {
  105. return getPhoneArticle(ticket, article)
  106. }
  107. const newArticle = getEmptyArticle(article)
  108. const sender = article.sender?.name
  109. const senderIsSystem = areAddressesSystem(article.from)
  110. const recipientIsSystem = areAddressesSystem(article.to)
  111. const senderEmail = article.createdBy.email
  112. const isSystem =
  113. !recipientIsSystem &&
  114. sender === 'Agent' &&
  115. senderEmail &&
  116. article.from?.parsed?.some((address) =>
  117. address.emailAddress?.toLowerCase().includes(senderEmail),
  118. )
  119. if (senderIsSystem) {
  120. newArticle.to = getEmailAddresses(article.replyTo || article.to)
  121. }
  122. // sender is agent - sent via system
  123. else if (isSystem) {
  124. newArticle.to = getEmailAddresses(article.to)
  125. }
  126. // sender was regular customer
  127. else {
  128. newArticle.to = getEmailAddresses(article.replyTo || article.from)
  129. if (!newArticle.to.length || newArticle.to.every((r) => !r.includes('@')))
  130. newArticle.to = senderEmail ? [senderEmail] : []
  131. }
  132. const emailsSeen = new Set<string>()
  133. if (newArticle.to.length) {
  134. newArticle.to = prepareEmails(emailsSeen, newArticle.to)
  135. }
  136. if (!all) {
  137. return newArticle
  138. }
  139. prepareAllEmails(emailsSeen, article, newArticle)
  140. return newArticle
  141. }
  142. export const replyToEmail = (
  143. ticket: TicketById,
  144. article: TicketArticle,
  145. options: TicketArticlePerformOptions,
  146. config: ConfigList,
  147. all = false,
  148. ) => {
  149. const newArticle = getRecipientArticle(ticket, article, all)
  150. if (config.ui_ticket_zoom_article_email_subject) {
  151. newArticle.subject = article.subject || ticket.title
  152. }
  153. // eslint-disable-next-line prefer-const
  154. let { content: selection, full } = getArticleSelection(
  155. options.selection,
  156. article,
  157. config,
  158. )
  159. if (selection) {
  160. const header = getReplyQuoteHeader(config, article)
  161. // data-full will be removed by the backend, it's used only for siganture handling
  162. selection = `<br><blockquote type="cite" ${
  163. full ? 'data-full="true"' : ''
  164. }>${header}${selection}</blockquote>`
  165. }
  166. const currentBody = options.getNewArticleBody('text/html')
  167. const body =
  168. (selection || '') +
  169. (currentBody && selection ? `<p></p>${currentBody}` : currentBody)
  170. // signature is handled in article type "onSelected" hook
  171. options.openReplyDialog({
  172. ...newArticle,
  173. subtype: 'reply',
  174. body,
  175. })
  176. }