Fix email notifications (#6328)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-08-13 15:12:12 +04:00 committed by GitHub
parent cc7240c7d9
commit 071551c66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 134 additions and 37 deletions

View File

@ -129,8 +129,8 @@ export function defineNotifications (builder: Builder): void {
defaultEnabled: false, defaultEnabled: false,
group: chunter.ids.ChunterNotificationGroup, group: chunter.ids.ChunterNotificationGroup,
templates: { templates: {
textTemplate: '{body}', textTemplate: '{sender} replied to {doc}:\n\n{message}',
htmlTemplate: '<p>{body}</p>', htmlTemplate: '<p><b>{sender}</b> replied to {doc}:</p><p>{message}</p><p>{link}</p>',
subjectTemplate: '{title}' subjectTemplate: '{title}'
} }
}, },

View File

@ -556,8 +556,8 @@ export function createModel (builder: Builder): void {
group: notification.ids.NotificationGroup, group: notification.ids.NotificationGroup,
defaultEnabled: true, defaultEnabled: true,
templates: { templates: {
textTemplate: '{sender} mentioned you in {doc} {message}', textTemplate: '{sender} mentioned you in {doc}: {message}',
htmlTemplate: '<p>{sender}</b> mentioned you in {doc}</p> {message}', htmlTemplate: '<p><b>{sender}</b> mentioned you in {doc}:</p> <p>{message}</p> <p>{link}</p>',
subjectTemplate: 'You were mentioned in {doc}' subjectTemplate: 'You were mentioned in {doc}'
} }
}, },

View File

@ -37,6 +37,10 @@ export function createModel (builder: Builder): void {
presenter: serverChunter.function.ChatMessageTextPresenter presenter: serverChunter.function.ChatMessageTextPresenter
}) })
builder.mixin(chunter.class.ChatMessage, core.class.Class, serverNotification.mixin.HTMLPresenter, {
presenter: serverChunter.function.ChatMessageTextPresenter
})
builder.mixin<Class<Doc>, ObjectDDParticipant>( builder.mixin<Class<Doc>, ObjectDDParticipant>(
chunter.class.ChatMessage, chunter.class.ChatMessage,
core.class.Class, core.class.Class,

View File

@ -56,6 +56,7 @@
"CommonNotificationCollectionRemoved": "{senderName} removed {collection}", "CommonNotificationCollectionRemoved": "{senderName} removed {collection}",
"Sound": "Sound", "Sound": "Sound",
"SoundNotificationsDescription": "Receive sound notifications for events.", "SoundNotificationsDescription": "Receive sound notifications for events.",
"NoAccessToObject": "You no longer have access to this object" "NoAccessToObject": "You no longer have access to this object",
"ViewIn": "View in {app}"
} }
} }

View File

@ -55,6 +55,7 @@
"SoundNotificationsDescription": "Reciba notificaciones de sonido para eventos.", "SoundNotificationsDescription": "Reciba notificaciones de sonido para eventos.",
"CommonNotificationCollectionAdded": "{senderName} añadió {collection}", "CommonNotificationCollectionAdded": "{senderName} añadió {collection}",
"CommonNotificationCollectionRemoved": "{senderName} eliminó {collection}", "CommonNotificationCollectionRemoved": "{senderName} eliminó {collection}",
"NoAccessToObject": "Ya no tienes acceso a este objeto" "NoAccessToObject": "Ya no tienes acceso a este objeto",
"ViewIn": "Ver en {app}"
} }
} }

View File

@ -56,6 +56,7 @@
"SoundNotificationsDescription": "Recevez des notifications sonores pour les événements.", "SoundNotificationsDescription": "Recevez des notifications sonores pour les événements.",
"CommonNotificationCollectionAdded": "{senderName} a ajouté {collection}", "CommonNotificationCollectionAdded": "{senderName} a ajouté {collection}",
"CommonNotificationCollectionRemoved": "{senderName} a supprimé {collection}", "CommonNotificationCollectionRemoved": "{senderName} a supprimé {collection}",
"NoAccessToObject": "Vous n'avez plus accès à cet objet" "NoAccessToObject": "Vous n'avez plus accès à cet objet",
"ViewIn": "Voir dans {app}"
} }
} }

View File

@ -55,6 +55,7 @@
"SoundNotificationsDescription": "Receba notificações sonoras para eventos.", "SoundNotificationsDescription": "Receba notificações sonoras para eventos.",
"CommonNotificationCollectionAdded": "{senderName} adicionou {collection}", "CommonNotificationCollectionAdded": "{senderName} adicionou {collection}",
"CommonNotificationCollectionRemoved": "{senderName} removeu {collection}", "CommonNotificationCollectionRemoved": "{senderName} removeu {collection}",
"NoAccessToObject": "Você não tem mais acesso a este objeto" "NoAccessToObject": "Você não tem mais acesso a este objeto",
"ViewIn": "Ver em {app}"
} }
} }

View File

@ -56,6 +56,7 @@
"SoundNotificationsDescription": "Получайте звуковые уведомления о событиях.", "SoundNotificationsDescription": "Получайте звуковые уведомления о событиях.",
"CommonNotificationCollectionAdded": "{senderName} добавил {collection}", "CommonNotificationCollectionAdded": "{senderName} добавил {collection}",
"CommonNotificationCollectionRemoved": "{senderName} удалил {collection}", "CommonNotificationCollectionRemoved": "{senderName} удалил {collection}",
"NoAccessToObject": "У вас больше нет доступа к этому объекту" "NoAccessToObject": "У вас больше нет доступа к этому объекту",
"ViewIn": "Посмотреть в {app}"
} }
} }

View File

@ -56,6 +56,7 @@
"SoundNotificationsDescription": "接收事件的声音通知。", "SoundNotificationsDescription": "接收事件的声音通知。",
"CommonNotificationCollectionAdded": "{senderName} 添加了 {collection}", "CommonNotificationCollectionAdded": "{senderName} 添加了 {collection}",
"CommonNotificationCollectionRemoved": "{senderName} 移除了 {collection}", "CommonNotificationCollectionRemoved": "{senderName} 移除了 {collection}",
"NoAccessToObject": "您不再可以访问此对象" "NoAccessToObject": "您不再可以访问此对象",
"ViewIn": "在 {app} 中查看"
} }
} }

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import activity, { ActivityMessage } from '@hcengineering/activity' import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter' import chunter from '@hcengineering/chunter'
import { getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder, Space } from '@hcengineering/core' import { Doc, getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder, Space } from '@hcengineering/core'
import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification' import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation' import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
import { import {
@ -31,7 +31,8 @@
Separator, Separator,
TabItem, TabItem,
TabList, TabList,
closePanel closePanel,
getCurrentLocation
} from '@hcengineering/ui' } from '@hcengineering/ui'
import view, { decodeObjectURI } from '@hcengineering/view' import view, { decodeObjectURI } from '@hcengineering/view'
import { parseLinkId } from '@hcengineering/view-resources' import { parseLinkId } from '@hcengineering/view-resources'
@ -143,11 +144,23 @@
$: filteredData = filterData(filter, selectedTabId, inboxData, $contextByIdStore) $: filteredData = filterData(filter, selectedTabId, inboxData, $contextByIdStore)
locationStore.subscribe((newLocation) => { const unsubscribeLoc = locationStore.subscribe((newLocation) => {
void syncLocation(newLocation) void syncLocation(newLocation, $contextByDocStore)
}) })
async function syncLocation (newLocation: Location): Promise<void> { let isContextsLoaded = false
const unsubscribeContexts = contextByDocStore.subscribe((docs) => {
if (selectedContext !== undefined || docs.size === 0 || isContextsLoaded) {
return
}
const loc = getCurrentLocation()
void syncLocation(loc, docs)
isContextsLoaded = true
})
async function syncLocation (newLocation: Location, contextByDoc: Map<Ref<Doc>, DocNotifyContext>): Promise<void> {
const loc = await resolveLocation(newLocation) const loc = await resolveLocation(newLocation)
if (loc?.loc.path[2] !== notificationId) { if (loc?.loc.path[2] !== notificationId) {
return return
@ -161,7 +174,7 @@
const [id, _class] = decodeObjectURI(loc?.loc.path[3] ?? '') const [id, _class] = decodeObjectURI(loc?.loc.path[3] ?? '')
const _id = await parseLinkId(linkProviders, id, _class) const _id = await parseLinkId(linkProviders, id, _class)
const context = _id ? $contextByDocStore.get(_id) : undefined const context = _id ? contextByDoc.get(_id) : undefined
selectedContextId = context?._id selectedContextId = context?._id
@ -357,7 +370,11 @@
} }
] ]
$: $deviceInfo.replacedPanel = replacedPanel $: $deviceInfo.replacedPanel = replacedPanel
onDestroy(() => ($deviceInfo.replacedPanel = undefined)) onDestroy(() => {
$deviceInfo.replacedPanel = undefined
unsubscribeLoc()
unsubscribeContexts()
})
</script> </script>
<ActionContext <ActionContext

View File

@ -427,7 +427,8 @@ const notification = plugin(notificationId, {
CommonNotificationCollectionRemoved: '' as IntlString, CommonNotificationCollectionRemoved: '' as IntlString,
SoundNotificationsDescription: '' as IntlString, SoundNotificationsDescription: '' as IntlString,
Sound: '' as IntlString, Sound: '' as IntlString,
NoAccessToObject: '' as IntlString NoAccessToObject: '' as IntlString,
ViewIn: '' as IntlString
}, },
function: { function: {
Notify: '' as Resource<NotifyFunc>, Notify: '' as Resource<NotifyFunc>,

View File

@ -53,7 +53,7 @@ import {
getDocCollaborators, getDocCollaborators,
getMixinTx getMixinTx
} from '@hcengineering/server-notification-resources' } from '@hcengineering/server-notification-resources'
import { markupToText, stripTags } from '@hcengineering/text' import { markupToHTML, markupToText, stripTags } from '@hcengineering/text'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
import { NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification' import { NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification'
@ -91,6 +91,10 @@ export async function ChatMessageTextPresenter (doc: ChatMessage): Promise<strin
return markupToText(doc.message) return markupToText(doc.message)
} }
export async function ChatMessageHtmlPresenter (doc: ChatMessage): Promise<string> {
return markupToHTML(doc.message)
}
/** /**
* @public * @public
*/ */
@ -580,6 +584,7 @@ export default async () => ({
ChannelTextPresenter: channelTextPresenter, ChannelTextPresenter: channelTextPresenter,
ChunterNotificationContentProvider: getChunterNotificationContent, ChunterNotificationContentProvider: getChunterNotificationContent,
ChatMessageTextPresenter, ChatMessageTextPresenter,
ChatMessageHtmlPresenter,
JoinChannelTypeMatch JoinChannelTypeMatch
} }
}) })

View File

@ -40,6 +40,7 @@ export default plugin(serverChunterId, {
ChannelTextPresenter: '' as Resource<Presenter>, ChannelTextPresenter: '' as Resource<Presenter>,
ChunterNotificationContentProvider: '' as Resource<NotificationContentProvider>, ChunterNotificationContentProvider: '' as Resource<NotificationContentProvider>,
ChatMessageTextPresenter: '' as Resource<Presenter>, ChatMessageTextPresenter: '' as Resource<Presenter>,
ChatMessageHtmlPresenter: '' as Resource<Presenter>,
JoinChannelTypeMatch: '' as TypeMatchFunc JoinChannelTypeMatch: '' as TypeMatchFunc
} }
}) })

View File

@ -37,13 +37,15 @@
"@types/jest": "^29.5.5" "@types/jest": "^29.5.5"
}, },
"dependencies": { "dependencies": {
"@hcengineering/activity": "^0.6.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32", "@hcengineering/core": "^0.6.32",
"@hcengineering/gmail": "^0.6.22",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
"@hcengineering/server-core": "^0.6.1", "@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-notification": "^0.6.1", "@hcengineering/server-notification": "^0.6.1",
"@hcengineering/server-notification-resources": "^0.6.0", "@hcengineering/server-notification-resources": "^0.6.0",
"@hcengineering/notification": "^0.6.23", "@hcengineering/text": "^0.6.5"
"@hcengineering/contact": "^0.6.24",
"@hcengineering/gmail": "^0.6.22"
} }
} }

View File

@ -39,6 +39,7 @@ import serverNotification, {
} from '@hcengineering/server-notification' } from '@hcengineering/server-notification'
import { getContentByTemplate } from '@hcengineering/server-notification-resources' import { getContentByTemplate } from '@hcengineering/server-notification-resources'
import { getMetadata } from '@hcengineering/platform' import { getMetadata } from '@hcengineering/platform'
import { ActivityMessage } from '@hcengineering/activity'
/** /**
* @public * @public
@ -134,7 +135,8 @@ async function notifyByEmail (
doc: Doc | undefined, doc: Doc | undefined,
sender: SenderInfo, sender: SenderInfo,
receiver: ReceiverInfo, receiver: ReceiverInfo,
data: InboxNotification data: InboxNotification,
message?: ActivityMessage
): Promise<void> { ): Promise<void> {
const account = receiver.account const account = receiver.account
@ -145,8 +147,7 @@ async function notifyByEmail (
const senderPerson = sender.person const senderPerson = sender.person
const senderName = senderPerson !== undefined ? formatName(senderPerson.name, control.branding?.lastNameFirst) : '' const senderName = senderPerson !== undefined ? formatName(senderPerson.name, control.branding?.lastNameFirst) : ''
const content = await getContentByTemplate(doc, senderName, type, control, '', data) const content = await getContentByTemplate(doc, senderName, type, control, '', data, message)
if (content !== undefined) { if (content !== undefined) {
await sendEmailNotification(control.ctx, content.text, content.html, content.subject, account.email) await sendEmailNotification(control.ctx, content.text, content.html, content.subject, account.email)
} }
@ -158,7 +159,8 @@ const SendEmailNotifications: NotificationProviderFunc = async (
object: Doc, object: Doc,
data: InboxNotification, data: InboxNotification,
receiver: ReceiverInfo, receiver: ReceiverInfo,
sender: SenderInfo sender: SenderInfo,
message?: ActivityMessage
): Promise<Tx[]> => { ): Promise<Tx[]> => {
if (types.length === 0) { if (types.length === 0) {
return [] return []
@ -169,7 +171,7 @@ const SendEmailNotifications: NotificationProviderFunc = async (
} }
for (const type of types) { for (const type of types) {
await notifyByEmail(control, type._id, object, sender, receiver, data) await notifyByEmail(control, type._id, object, sender, receiver, data, message)
} }
return [] return []

View File

@ -80,7 +80,7 @@ import serverNotification, {
SenderInfo SenderInfo
} from '@hcengineering/server-notification' } from '@hcengineering/server-notification'
import serverView from '@hcengineering/server-view' import serverView from '@hcengineering/server-view'
import { stripTags } from '@hcengineering/text' import { markupToText, stripTags } from '@hcengineering/text'
import { encodeObjectURI } from '@hcengineering/view' import { encodeObjectURI } from '@hcengineering/view'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
import webpush, { WebPushError } from 'web-push' import webpush, { WebPushError } from 'web-push'
@ -91,6 +91,7 @@ import {
createPushCollaboratorsTx, createPushCollaboratorsTx,
getHTMLPresenter, getHTMLPresenter,
getNotificationContent, getNotificationContent,
getNotificationLink,
getTextPresenter, getTextPresenter,
getUsersInfo, getUsersInfo,
isAllowed, isAllowed,
@ -98,6 +99,7 @@ import {
isShouldNotifyTx, isShouldNotifyTx,
isUserEmployeeInFieldValue, isUserEmployeeInFieldValue,
isUserInFieldValue, isUserInFieldValue,
messageToMarkup,
replaceAll, replaceAll,
toReceiverInfo, toReceiverInfo,
updateNotifyContextsSpace updateNotifyContextsSpace
@ -212,7 +214,8 @@ export async function getContentByTemplate (
type: Ref<BaseNotificationType>, type: Ref<BaseNotificationType>,
control: TriggerControl, control: TriggerControl,
data: string, data: string,
notificationData?: InboxNotification notificationData?: InboxNotification,
message?: ActivityMessage
): Promise<Content | undefined> { ): Promise<Content | undefined> {
if (doc === undefined) return if (doc === undefined) return
const notificationType = control.modelDb.getObject(type) const notificationType = control.modelDb.getObject(type)
@ -220,11 +223,24 @@ export async function getContentByTemplate (
const textPart = await getTextPart(doc, control) const textPart = await getTextPart(doc, control)
if (textPart === undefined) return if (textPart === undefined) return
const params = const params: Record<string, string> =
notificationData !== undefined notificationData !== undefined
? await getTranslatedNotificationContent(notificationData, notificationData._class, control) ? await getTranslatedNotificationContent(notificationData, notificationData._class, control)
: {} : {}
if (message !== undefined) {
const markup = await messageToMarkup(control, message)
params.message = markup !== undefined ? markupToText(markup) : params.message ?? ''
} else if (params.message === undefined) {
params.message = params.body ?? ''
}
const link = await getNotificationLink(control, doc, message?._id)
const app = control.branding?.title ?? 'Huly'
const linkText = await translate(notification.string.ViewIn, { app })
params.link = `<a href='${link}'>${linkText}</a>`
const text = fillTemplate(notificationType.templates.textTemplate, sender, textPart, data, params) const text = fillTemplate(notificationType.templates.textTemplate, sender, textPart, data, params)
const htmlPart = await getHtmlPart(doc, control) const htmlPart = await getHtmlPart(doc, control)
const html = fillTemplate(notificationType.templates.htmlTemplate, sender, htmlPart ?? textPart, data, params) const html = fillTemplate(notificationType.templates.htmlTemplate, sender, htmlPart ?? textPart, data, params)
@ -838,16 +854,24 @@ export async function createCollabDocInfo (
return res return res
} }
const notifyContexts = await control.findAllCtx(ctx, notification.class.DocNotifyContext, { objectId: object._id })
await updateContextsTimestamp(notifyContexts, originTx.modifiedOn, control, originTx.modifiedBy)
await removeContexts(notifyContexts, unsubscribe, control)
const docMessages = activityMessages.filter((message) => message.attachedTo === object._id) const docMessages = activityMessages.filter((message) => message.attachedTo === object._id)
if (docMessages.length === 0) { if (docMessages.length === 0) {
if (unsubscribe.length > 0) {
const notifyContexts = await control.findAllCtx(ctx, notification.class.DocNotifyContext, {
objectId: object._id,
user: { $in: unsubscribe }
})
await removeContexts(notifyContexts, unsubscribe, control)
}
return res return res
} }
const notifyContexts = await control.findAllCtx(ctx, notification.class.DocNotifyContext, { objectId: object._id })
await removeContexts(notifyContexts, unsubscribe, control)
await updateContextsTimestamp(notifyContexts, originTx.modifiedOn, control, originTx.modifiedBy)
const targets = new Set(collaborators) const targets = new Set(collaborators)
// user is not collaborator of himself, but we should notify user of changes related to users account (mentions, comments etc) // user is not collaborator of himself, but we should notify user of changes related to users account (mentions, comments etc)

View File

@ -55,10 +55,11 @@ import serverNotification, {
SenderInfo, SenderInfo,
TextPresenter TextPresenter
} from '@hcengineering/server-notification' } from '@hcengineering/server-notification'
import { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity' import activity, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity'
import serverView from '@hcengineering/server-view' import serverView from '@hcengineering/server-view'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
import { encodeObjectURI } from '@hcengineering/view' import { encodeObjectURI } from '@hcengineering/view'
import chunter, { ChatMessage } from '@hcengineering/chunter'
import { NotifyResult } from './types' import { NotifyResult } from './types'
@ -568,11 +569,45 @@ export async function getNotificationLink (
id = await encodeFn(doc, control) id = await encodeFn(doc, control)
} }
let thread: string | undefined
if (control.hierarchy.isDerived(doc._class, activity.class.ActivityMessage)) {
const id = (doc as ActivityMessage)._id
if (message === undefined) {
message = id
} else {
thread = id
}
}
const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = [workbenchId, 'platform', notificationId, encodeObjectURI(id, doc._class)] const path = [workbenchId, control.workspace.workspaceUrl, notificationId, encodeObjectURI(id, doc._class), thread]
.filter((x): x is string => x !== undefined)
.map((p) => encodeURIComponent(p)) .map((p) => encodeURIComponent(p))
.join('/') .join('/')
const link = concatLink(front, path) const link = concatLink(front, path)
return message !== undefined ? `${link}?message=${message}` : link return message !== undefined ? `${link}?message=${message}` : link
} }
export async function messageToMarkup (control: TriggerControl, message: ActivityMessage): Promise<string | undefined> {
const { hierarchy } = control
if (hierarchy.isDerived(message._class, chunter.class.ChatMessage)) {
const chatMessage = message as ChatMessage
return chatMessage.message
} else {
const resource = getTextPresenter(message._class, control.hierarchy)
if (resource !== undefined) {
const fn = await getResource(resource.presenter)
const textData = await fn(message, control)
if (textData !== undefined && textData !== '') {
return textData
}
}
}
return undefined
}