reply.ts 5.7 KB

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