snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
3
apps/meteor/server/lib/notifications/core/index.ts
Normal file
3
apps/meteor/server/lib/notifications/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Notifications from './lib/Notifications';
|
||||
|
||||
export { Notifications };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { ServerMethods } from '@rocket.chat/ddp-client';
|
||||
import { DDPCommon } from 'meteor/ddp-common';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { NotificationsModule } from '../../../../modules/notifications/notifications.module';
|
||||
import { Streamer } from '../../../../modules/streamer/streamer.module';
|
||||
|
||||
import './Presence';
|
||||
|
||||
class Stream extends Streamer<'local'> {
|
||||
registerPublication(name: string, fn: (eventName: string, options: boolean | { useCollection?: boolean; args?: any }) => void): void {
|
||||
Meteor.publish(name, function (eventName, options) {
|
||||
return fn.call(this, eventName, options);
|
||||
});
|
||||
}
|
||||
|
||||
registerMethod(methods: Partial<ServerMethods>): void {
|
||||
Meteor.methods(methods);
|
||||
}
|
||||
|
||||
changedPayload(collection: string, id: string, fields: Record<string, any>): string | false {
|
||||
return DDPCommon.stringifyDDP({
|
||||
msg: 'changed',
|
||||
collection,
|
||||
id,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const notifications = new NotificationsModule(Stream);
|
||||
|
||||
notifications.configure();
|
||||
|
||||
notifications.streamLocal.on('broadcast', ({ eventName, args }) => {
|
||||
void api.broadcastLocal(eventName, ...args);
|
||||
});
|
||||
|
||||
export default notifications;
|
||||
200
apps/meteor/server/lib/notifications/core/lib/Presence.ts
Normal file
200
apps/meteor/server/lib/notifications/core/lib/Presence.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { StatusVisibility } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { USER_STATUS_TO_PRESENCE_CODE, UserStatus } from '@rocket.chat/core-typings';
|
||||
import type { StreamerEvents } from '@rocket.chat/ddp-client';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
import { Streamer } from '../../../../modules/streamer/streamer.module';
|
||||
import type { IPublication, IStreamerConstructor, Connection, IStreamer } from '../../../../modules/streamer/types';
|
||||
import { statusVisibilityGate } from '../../../statusVisibility/StatusVisibilityGate';
|
||||
|
||||
type UserPresenceStreamProps = {
|
||||
added: IUser['_id'][];
|
||||
removed: IUser['_id'][];
|
||||
};
|
||||
|
||||
type UserPresenceStreamArgs = {
|
||||
uid: string;
|
||||
args: StreamerEvents['user-presence'][number]['args'];
|
||||
};
|
||||
|
||||
const e = new Emitter<{
|
||||
[key: string]: UserPresenceStreamArgs;
|
||||
}>();
|
||||
|
||||
const clients = new WeakMap<Connection, UserPresence>();
|
||||
|
||||
// mirror of `clients`, which cannot be enumerated
|
||||
const liveClients = new Set<UserPresence>();
|
||||
|
||||
class UserPresence {
|
||||
private readonly streamer: IStreamer<'user-presence'>;
|
||||
|
||||
private readonly publication: IPublication;
|
||||
|
||||
private readonly listeners: Set<string>;
|
||||
|
||||
// map value as true marks a pending correction
|
||||
private hiddenFrom = new Map<IUser['_id'], true | undefined>();
|
||||
|
||||
private stale = true;
|
||||
|
||||
constructor(publication: IPublication, streamer: IStreamer<'user-presence'>) {
|
||||
this.listeners = new Set();
|
||||
this.publication = publication;
|
||||
this.streamer = streamer;
|
||||
}
|
||||
|
||||
listen(uid: string): void {
|
||||
if (this.listeners.has(uid)) {
|
||||
return;
|
||||
}
|
||||
e.on(uid, this.run);
|
||||
this.listeners.add(uid);
|
||||
}
|
||||
|
||||
off = (uid: string): void => {
|
||||
e.off(uid, this.run);
|
||||
this.listeners.delete(uid);
|
||||
};
|
||||
|
||||
get isStale() {
|
||||
return this.stale;
|
||||
}
|
||||
|
||||
async refreshHiddenFrom(): Promise<void> {
|
||||
if (!(await statusVisibilityGate.ensureEnabled())) {
|
||||
if (this.hiddenFrom.size) {
|
||||
this.hiddenFrom = new Map();
|
||||
}
|
||||
this.stale = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = this.hiddenFrom;
|
||||
|
||||
try {
|
||||
const hidden = await StatusVisibility.getHiddenFrom(this.publication._session?.userId);
|
||||
|
||||
this.hiddenFrom = new Map(hidden.map((uid) => [uid, !previous.has(uid) && this.listeners.has(uid) ? true : undefined]));
|
||||
this.stale = false;
|
||||
} catch (error) {
|
||||
this.stale = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
run = (args: UserPresenceStreamArgs): void => {
|
||||
const hidden = this.hiddenFrom.has(args.uid);
|
||||
|
||||
if (hidden) {
|
||||
if (!this.hiddenFrom.get(args.uid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hiddenFrom.set(args.uid, undefined);
|
||||
}
|
||||
|
||||
const visiblePresence: UserPresenceStreamArgs = hidden
|
||||
? { uid: args.uid, args: [[args.args[0][0], USER_STATUS_TO_PRESENCE_CODE[UserStatus.OFFLINE]]] }
|
||||
: args;
|
||||
const payload = this.streamer.changedPayload(this.streamer.subscriptionName, args.uid, { ...visiblePresence, eventName: args.uid }); // there is no good explanation to keep eventName, I just want to save one 'DDPCommon.parseDDP' on the client side, so I'm trying to fit the Meteor Streamer's payload
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
// after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong)
|
||||
// we were just not counting on this, session is _session so we actually should not use it
|
||||
// now after any await, the session can potentially be null, so we need to check for that
|
||||
if (!Streamer.isPublicationActive(this.publication)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.publication._session.socket.send(payload);
|
||||
};
|
||||
|
||||
get viewerId(): IUser['_id'] | undefined {
|
||||
return this.publication._session?.userId;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.listeners.forEach(this.off);
|
||||
clients.delete(this.publication.connection);
|
||||
liveClients.delete(this);
|
||||
}
|
||||
|
||||
static getClient(publication: IPublication, streamer: IStreamer<'user-presence'>): [UserPresence, boolean] {
|
||||
const { connection } = publication;
|
||||
const stored = clients.get(connection);
|
||||
|
||||
const client = stored || new UserPresence(publication, streamer);
|
||||
|
||||
const main = Boolean(!stored);
|
||||
|
||||
clients.set(connection, client);
|
||||
liveClients.add(client);
|
||||
|
||||
return [client, main];
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamPresence {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
static getInstance(StreamerClass: IStreamerConstructor, name = 'user-presence'): IStreamer<'user-presence'> {
|
||||
return new (
|
||||
class StreamPresence extends StreamerClass<'user-presence'> {
|
||||
override async _publish(
|
||||
publication: IPublication,
|
||||
_eventName: string,
|
||||
options: boolean | { useCollection?: boolean; args?: any } = false,
|
||||
): Promise<void> {
|
||||
const { added, removed } = (typeof options !== 'boolean' ? options : {}) as unknown as UserPresenceStreamProps;
|
||||
|
||||
const [client, main] = UserPresence.getClient(publication, this);
|
||||
|
||||
if (client.isStale) {
|
||||
try {
|
||||
await client.refreshHiddenFrom();
|
||||
} catch (error) {
|
||||
if (main) {
|
||||
client.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Streamer.isPublicationActive(publication)) {
|
||||
if (main) {
|
||||
client.stop();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
added?.forEach((uid) => client.listen(uid));
|
||||
removed?.forEach((uid) => client.off(uid));
|
||||
|
||||
if (!main) {
|
||||
publication.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
publication.ready();
|
||||
|
||||
publication.onStop(() => client.stop());
|
||||
}
|
||||
} as any
|
||||
)(name);
|
||||
}
|
||||
}
|
||||
|
||||
export const emit = (uid: string, args: UserPresenceStreamArgs['args']): void => {
|
||||
e.emit(uid, { uid, args });
|
||||
};
|
||||
|
||||
// no viewers means the setting itself changed, so every client has to re-pull
|
||||
export const refreshVisibility = async (viewers?: IUser['_id'][]): Promise<void> => {
|
||||
const affected = viewers && new Set(viewers);
|
||||
const clients = affected ? Array.from(liveClients).filter(({ viewerId }) => viewerId && affected.has(viewerId)) : liveClients;
|
||||
|
||||
await Promise.allSettled(Array.from(clients, (client) => client.refreshHiddenFrom()));
|
||||
};
|
||||
224
apps/meteor/server/lib/notifications/email/api.ts
Normal file
224
apps/meteor/server/lib/notifications/email/api.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { AppEvents, Apps } from '@rocket.chat/apps';
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { escapeHTML, validateEmail } from '@rocket.chat/tools';
|
||||
import juice from 'juice';
|
||||
import { Email } from 'meteor/email';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { stripHtml } from 'string-strip-html';
|
||||
import _ from 'underscore';
|
||||
|
||||
import { replaceVariables } from './replaceVariables';
|
||||
import { strLeft, strRightBack } from '../../../../lib/utils/stringUtils';
|
||||
import { settings } from '../../../settings';
|
||||
import { i18n } from '../../i18n';
|
||||
import { notifyOnSettingChanged } from '../../notifyListener';
|
||||
|
||||
let contentHeader: string | undefined;
|
||||
let contentFooter: string | undefined;
|
||||
let body: string | undefined;
|
||||
|
||||
// define server language for email translations
|
||||
// @TODO: change TAPi18n.__ function to use the server language by default
|
||||
let lng = 'en';
|
||||
settings.watch<string>('Language', (value) => {
|
||||
lng = value || 'en';
|
||||
});
|
||||
|
||||
export const replacekey = (str: string, key: string, value = ''): string =>
|
||||
str.replace(new RegExp(`(\\[${key}\\]|__${key}__)`, 'igm'), value);
|
||||
|
||||
export const translate = (str: string): string => replaceVariables(str, (_match, key) => i18n.t(key, { lng }));
|
||||
|
||||
export const replace = (str: string, data: { [key: string]: unknown } = {}): string => {
|
||||
if (!str) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const options = {
|
||||
Site_Name: settings.get<string>('Site_Name'),
|
||||
Site_URL: settings.get<string>('Site_Url'),
|
||||
Site_URL_Slash: settings.get<string>('Site_Url')?.replace(/\/?$/, '/'),
|
||||
...(data.name
|
||||
? {
|
||||
fname: strLeft(String(data.name), ' '),
|
||||
lname: strRightBack(String(data.name), ' '),
|
||||
}
|
||||
: {}),
|
||||
...data,
|
||||
};
|
||||
|
||||
return Object.entries(options).reduce((ret, [key, value]) => replacekey(ret, key, value), translate(str));
|
||||
};
|
||||
|
||||
const nonEscapeKeys = ['room_path'];
|
||||
|
||||
export const replaceEscaped = (str: string, data: { [key: string]: unknown } = {}): string => {
|
||||
const siteName = settings.get<string>('Site_Name');
|
||||
const siteUrl = settings.get<string>('Site_Url');
|
||||
|
||||
return replace(str, {
|
||||
Site_Name: siteName ? escapeHTML(siteName) : undefined,
|
||||
Site_Url: siteUrl ? escapeHTML(siteUrl) : undefined,
|
||||
...Object.entries(data).reduce<{ [key: string]: string }>((ret, [key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
ret[key] = nonEscapeKeys.includes(key) ? String(value) : escapeHTML(String(value));
|
||||
}
|
||||
return ret;
|
||||
}, {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const wrap = (html: string, data: { [key: string]: unknown } = {}): string => {
|
||||
if (settings.get('email_plain_text_only')) {
|
||||
return replace(html, data);
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
throw new Error('error-email-body-not-initialized');
|
||||
}
|
||||
|
||||
return replaceEscaped(body.replace('{{body}}', html), data);
|
||||
};
|
||||
|
||||
export const inlinecss = (html: string): string => {
|
||||
const css = settings.get<string>('email_style');
|
||||
return css ? juice.inlineContent(html, css) : html;
|
||||
};
|
||||
|
||||
export const getTemplate = (template: ISetting['_id'], fn: (html: string) => void, escape = true): void => {
|
||||
let html = '';
|
||||
|
||||
settings.watch<string>(template, (value) => {
|
||||
html = value || '';
|
||||
fn(escape ? inlinecss(html) : html);
|
||||
});
|
||||
|
||||
settings.watch<string>('email_style', () => {
|
||||
fn(escape ? inlinecss(html) : html);
|
||||
});
|
||||
};
|
||||
|
||||
export const getTemplateWrapped = (template: ISetting['_id'], fn: (html: string) => void): void => {
|
||||
let html = '';
|
||||
const wrapInlineCSS = _.debounce(() => fn(wrap(inlinecss(html))), 100);
|
||||
|
||||
settings.watch<string>('Email_Header', () => html && wrapInlineCSS());
|
||||
settings.watch<string>('Email_Footer', () => html && wrapInlineCSS());
|
||||
settings.watch<string>('email_style', () => html && wrapInlineCSS());
|
||||
settings.watch<string>(template, (value) => {
|
||||
html = value || '';
|
||||
return html && wrapInlineCSS();
|
||||
});
|
||||
};
|
||||
|
||||
settings.watchMultiple(['Email_Header', 'Email_Footer'], () => {
|
||||
getTemplate(
|
||||
'Email_Header',
|
||||
(value) => {
|
||||
contentHeader = replace(value || '');
|
||||
body = inlinecss(`${contentHeader} {{body}} ${contentFooter}`);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
getTemplate(
|
||||
'Email_Footer',
|
||||
(value) => {
|
||||
contentFooter = replace(value || '');
|
||||
body = inlinecss(`${contentHeader} {{body}} ${contentFooter}`);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
body = inlinecss(`${contentHeader} {{body}} ${contentFooter}`);
|
||||
});
|
||||
|
||||
export const checkAddressFormat = (adresses: string | string[]): boolean =>
|
||||
([] as string[]).concat(adresses).every((address) => validateEmail(address));
|
||||
|
||||
export const sendNoWrap = async ({
|
||||
to,
|
||||
from,
|
||||
replyTo,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
headers,
|
||||
}: {
|
||||
to: string | string[];
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
headers?: string;
|
||||
}) => {
|
||||
if (!checkAddressFormat(to)) {
|
||||
throw new Meteor.Error('invalid email');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
text = html ? stripHtml(html).result : undefined;
|
||||
}
|
||||
|
||||
if (settings.get('email_plain_text_only')) {
|
||||
html = undefined;
|
||||
}
|
||||
|
||||
const value = await Settings.incrementValueById('Triggered_Emails_Count', 1, { returnDocument: 'after' });
|
||||
if (value) {
|
||||
void notifyOnSettingChanged(value);
|
||||
}
|
||||
|
||||
const email = { to, from, replyTo, subject, html, text, headers };
|
||||
|
||||
const eventResult = await Apps.self?.triggerEvent(AppEvents.IPreEmailSent, { email });
|
||||
|
||||
setImmediate(() => Email.sendAsync(eventResult || email).catch((e) => console.error(e)));
|
||||
};
|
||||
|
||||
export const send = async ({
|
||||
to,
|
||||
from,
|
||||
replyTo,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
data,
|
||||
headers,
|
||||
}: {
|
||||
to: string | string[];
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
headers?: string;
|
||||
data?: { [key: string]: unknown };
|
||||
}): Promise<void> =>
|
||||
sendNoWrap({
|
||||
to,
|
||||
from,
|
||||
replyTo,
|
||||
subject: replace(subject, data),
|
||||
text: (text && replace(text, data)) || (html && stripHtml(replace(html, data)).result) || undefined,
|
||||
html: html ? wrap(html, data) : undefined,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Needed because of https://github.com/microsoft/TypeScript/issues/36931
|
||||
type Assert = (input: string, func: string) => asserts input;
|
||||
export const checkAddressFormatAndThrow: Assert = (from: string, func: string): asserts from => {
|
||||
if (checkAddressFormat(from)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Meteor.Error('error-invalid-from-address', 'Invalid from address', {
|
||||
function: func,
|
||||
});
|
||||
};
|
||||
|
||||
export const getHeader = (): string | undefined => contentHeader;
|
||||
|
||||
export const getFooter = (): string | undefined => contentFooter;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const replaceVariables = (str: string, replacer: (substring: string, key: string) => string): string =>
|
||||
str.replace(/\{ *([^\{\} ]+)[^\{\}]*\}/gim, replacer);
|
||||
@@ -0,0 +1,151 @@
|
||||
import POP3Lib from '@rocket.chat/poplib';
|
||||
import { simpleParser } from 'mailparser';
|
||||
|
||||
import { processDirectEmail } from './processDirectEmail';
|
||||
import { IMAPInterceptor } from '../../email/IMAPInterceptor';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
export class DirectReplyIMAPInterceptor extends IMAPInterceptor {
|
||||
constructor(imapConfig, options = {}) {
|
||||
imapConfig = {
|
||||
user: settings.get('Direct_Reply_Username'),
|
||||
password: settings.get('Direct_Reply_Password'),
|
||||
host: settings.get('Direct_Reply_Host'),
|
||||
port: settings.get('Direct_Reply_Port'),
|
||||
debug: settings.get('Direct_Reply_Debug') ? console.log : false,
|
||||
tls: !settings.get('Direct_Reply_IgnoreTLS'),
|
||||
...imapConfig,
|
||||
};
|
||||
|
||||
options.deleteAfterRead = settings.get('Direct_Reply_Delete');
|
||||
|
||||
super(imapConfig, options);
|
||||
|
||||
this.on('email', (email) => processDirectEmail(email));
|
||||
}
|
||||
}
|
||||
|
||||
class POP3Intercepter {
|
||||
constructor() {
|
||||
this.pop3 = new POP3Lib(settings.get('Direct_Reply_Port'), settings.get('Direct_Reply_Host'), {
|
||||
enabletls: !settings.get('Direct_Reply_IgnoreTLS'),
|
||||
// debug: settings.get('Direct_Reply_Debug') ? console.log : false,
|
||||
debug: console.log,
|
||||
});
|
||||
|
||||
this.totalMsgCount = 0;
|
||||
this.currentMsgCount = 0;
|
||||
|
||||
this.pop3.on('connect', () => {
|
||||
console.log('Pop connect');
|
||||
this.pop3.login(settings.get('Direct_Reply_Username'), settings.get('Direct_Reply_Password'));
|
||||
});
|
||||
|
||||
this.pop3.on('login', (status) => {
|
||||
if (!status) {
|
||||
return console.log('Unable to Log-in ....');
|
||||
}
|
||||
console.log('Pop logged');
|
||||
// run on start
|
||||
this.pop3.list();
|
||||
});
|
||||
|
||||
// on getting list of all emails
|
||||
this.pop3.on('list', (status, msgcount) => {
|
||||
if (!status) {
|
||||
console.log('Cannot Get Emails ....');
|
||||
}
|
||||
if (msgcount === 0) {
|
||||
return this.pop3.quit();
|
||||
}
|
||||
|
||||
this.totalMsgCount = msgcount;
|
||||
this.currentMsgCount = 1;
|
||||
// Retrieve email
|
||||
this.pop3.retr(this.currentMsgCount);
|
||||
});
|
||||
|
||||
// on retrieved email
|
||||
this.pop3.on('retr', async (status, msgnumber, data) => {
|
||||
if (!status) {
|
||||
return console.log('Cannot Retrieve Message ....');
|
||||
}
|
||||
|
||||
// parse raw email data to JSON object
|
||||
simpleParser(data, (err, mail) => {
|
||||
processDirectEmail(mail);
|
||||
});
|
||||
|
||||
this.currentMsgCount += 1;
|
||||
|
||||
// delete email
|
||||
this.pop3.dele(msgnumber);
|
||||
});
|
||||
|
||||
// on email deleted
|
||||
this.pop3.on('dele', (status) => {
|
||||
if (!status) {
|
||||
return console.log('Cannot Delete Message....');
|
||||
}
|
||||
|
||||
// get next email
|
||||
if (this.currentMsgCount <= this.totalMsgCount) {
|
||||
return this.pop3.retr(this.currentMsgCount);
|
||||
}
|
||||
|
||||
// parsed all messages.. so quitting
|
||||
this.pop3.quit();
|
||||
});
|
||||
|
||||
// invalid server state
|
||||
this.pop3.on('invalid-state', (cmd) => {
|
||||
console.log(`Invalid state. You tried calling ${cmd}`);
|
||||
});
|
||||
|
||||
this.pop3.on('error', (cmd) => {
|
||||
console.log(`error state. You tried calling ${cmd}`);
|
||||
});
|
||||
|
||||
// locked => command already running, not finished yet
|
||||
this.pop3.on('locked', (cmd) => {
|
||||
console.log(`Current command has not finished yet. You tried calling ${cmd}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class POP3Helper {
|
||||
constructor(frequency) {
|
||||
this.frequency = frequency;
|
||||
this.running = false;
|
||||
|
||||
this.POP3 = new POP3Intercepter();
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.log('POP3 started');
|
||||
this.running = setInterval(
|
||||
() => {
|
||||
// get new emails and process
|
||||
this.POP3 = new POP3Intercepter();
|
||||
},
|
||||
Math.max(this.frequency * 60 * 1000, 2 * 60 * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
stop(callback = new Function()) {
|
||||
this.log('POP3 stop called');
|
||||
if (this.isActive()) {
|
||||
clearInterval(this.running);
|
||||
}
|
||||
callback();
|
||||
this.log('POP3 stopped');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import EJSON from 'ejson';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { generatePath } from '../../../../../lib/utils/generatePath';
|
||||
import { SystemLogger } from '../../../logger/system';
|
||||
import { placeholders } from '../../../utils/placeholders';
|
||||
import * as Mailer from '../../email/api';
|
||||
|
||||
export const sendMail = async function ({
|
||||
from,
|
||||
subject,
|
||||
body,
|
||||
dryrun,
|
||||
query,
|
||||
}: {
|
||||
from: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
dryrun?: boolean;
|
||||
query?: string;
|
||||
}): Promise<void> {
|
||||
Mailer.checkAddressFormatAndThrow(from, 'Mailer.sendMail');
|
||||
|
||||
if (body.indexOf('[unsubscribe]') === -1) {
|
||||
throw new Meteor.Error('error-missing-unsubscribe-link', 'You must provide the [unsubscribe] link.', {
|
||||
function: 'Mailer.sendMail',
|
||||
});
|
||||
}
|
||||
|
||||
let userQuery: Filter<any> = { 'mailer.unsubscribed': { $exists: 0 } };
|
||||
if (query) {
|
||||
userQuery = { $and: [userQuery, EJSON.parse(query)] };
|
||||
}
|
||||
|
||||
if (dryrun) {
|
||||
const user = await Users.findOneByEmailAddress(from);
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
function: 'Mailer.sendMail',
|
||||
});
|
||||
}
|
||||
|
||||
const email = `${user.name} <${user.emails?.[0].address}>`;
|
||||
const html = placeholders.replace(body, {
|
||||
unsubscribe: Meteor.absoluteUrl(
|
||||
generatePath('mailer/unsubscribe/:_id/:createdAt', {
|
||||
_id: user._id,
|
||||
createdAt: user.createdAt?.getTime().toString() || '',
|
||||
}),
|
||||
),
|
||||
name: user.name,
|
||||
email,
|
||||
});
|
||||
|
||||
SystemLogger.debug({
|
||||
msg: 'Sending email',
|
||||
email,
|
||||
});
|
||||
return Mailer.send({
|
||||
to: email,
|
||||
from,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await Users.find(userQuery).toArray();
|
||||
|
||||
for await (const u of users) {
|
||||
const user: Partial<IUser> & Pick<IUser, '_id'> = u;
|
||||
if (user?.emails && Array.isArray(user.emails) && user.emails.length) {
|
||||
const email = `${user.name} <${user.emails[0].address}>`;
|
||||
|
||||
const html = placeholders.replace(body, {
|
||||
unsubscribe: Meteor.absoluteUrl(
|
||||
generatePath('mailer/unsubscribe/:_id/:createdAt', {
|
||||
_id: user._id,
|
||||
createdAt: user.createdAt?.getTime().toString() || '',
|
||||
}),
|
||||
),
|
||||
name: escapeHTML(user.name || ''),
|
||||
email: escapeHTML(email),
|
||||
});
|
||||
SystemLogger.debug({
|
||||
msg: 'Sending email',
|
||||
email,
|
||||
});
|
||||
await Mailer.send({
|
||||
to: email,
|
||||
from,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { SystemLogger } from '../../../logger/system';
|
||||
|
||||
export const unsubscribe = async function (_id: string, createdAt: string): Promise<boolean> {
|
||||
if (_id && createdAt) {
|
||||
const affectedRows = (await Users.rocketMailUnsubscribe(_id, createdAt)) === 1;
|
||||
|
||||
SystemLogger.debug({
|
||||
msg: '[Mailer:Unsubscribe]',
|
||||
_id,
|
||||
createdAt,
|
||||
affectedRows,
|
||||
});
|
||||
|
||||
return affectedRows;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sendMail } from '../functions/sendMail';
|
||||
import { unsubscribe } from '../functions/unsubscribe';
|
||||
|
||||
export const Mailer = {
|
||||
sendMail,
|
||||
unsubscribe,
|
||||
};
|
||||
125
apps/meteor/server/lib/notifications/message/desktop.ts
Normal file
125
apps/meteor/server/lib/notifications/message/desktop.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { metrics } from '../../metrics';
|
||||
import { roomCoordinator } from '../../rooms/roomCoordinator';
|
||||
|
||||
/**
|
||||
* Send notification to user
|
||||
*
|
||||
* @param {string} userId The user to notify
|
||||
* @param {object} user The sender
|
||||
* @param {object} room The room send from
|
||||
* @param {object} message The message object
|
||||
* @param {number} duration Duration of notification
|
||||
* @param {string} notificationMessage The message text to send on notification body
|
||||
*/
|
||||
export async function notifyDesktopUser({
|
||||
userId,
|
||||
user,
|
||||
message,
|
||||
room,
|
||||
duration,
|
||||
notificationMessage,
|
||||
audioNotificationValue,
|
||||
}: {
|
||||
userId: string;
|
||||
user: AtLeast<IUser, '_id' | 'name' | 'username'>;
|
||||
message: IMessage | Pick<IMessage, 'u'>;
|
||||
room: IRoom;
|
||||
duration?: number;
|
||||
notificationMessage: string;
|
||||
audioNotificationValue?: string;
|
||||
}): Promise<void> {
|
||||
const { title, text, name } = await roomCoordinator
|
||||
.getRoomDirectives(room.t)
|
||||
.getNotificationDetails(room, user, notificationMessage, userId);
|
||||
|
||||
const payload = {
|
||||
title: title || '',
|
||||
text,
|
||||
duration,
|
||||
payload: {
|
||||
_id: '',
|
||||
rid: '',
|
||||
tmid: '',
|
||||
...('_id' in message && {
|
||||
// TODO: omnichannel is not sending _id, rid, tmid
|
||||
_id: message._id,
|
||||
rid: message.rid,
|
||||
tmid: message.tmid,
|
||||
}),
|
||||
sender: message.u,
|
||||
type: room.t,
|
||||
message: {
|
||||
msg: 'msg' in message ? message.msg : '',
|
||||
...('t' in message && {
|
||||
t: message.t,
|
||||
}),
|
||||
...('content' in message && {
|
||||
content: message.content,
|
||||
}),
|
||||
},
|
||||
name,
|
||||
audioNotificationValue,
|
||||
},
|
||||
};
|
||||
|
||||
metrics.notificationsSent.inc({ notification_type: 'desktop' });
|
||||
metrics.notificationsSentTotal.inc({ notification_type: 'desktop' });
|
||||
|
||||
void api.broadcast('notify.desktop', userId, payload);
|
||||
}
|
||||
|
||||
export function shouldNotifyDesktop({
|
||||
disableAllMessageNotifications,
|
||||
status,
|
||||
statusConnection,
|
||||
desktopNotifications,
|
||||
hasMentionToAll,
|
||||
hasMentionToHere,
|
||||
isHighlighted,
|
||||
hasMentionToUser,
|
||||
hasReplyToThread,
|
||||
roomType,
|
||||
isThread,
|
||||
}: {
|
||||
disableAllMessageNotifications: boolean;
|
||||
status: string;
|
||||
statusConnection: string;
|
||||
desktopNotifications: string | undefined;
|
||||
hasMentionToAll: boolean;
|
||||
hasMentionToHere: boolean;
|
||||
isHighlighted: boolean;
|
||||
hasMentionToUser: boolean;
|
||||
hasReplyToThread: boolean;
|
||||
roomType: string;
|
||||
isThread: boolean;
|
||||
}): boolean {
|
||||
if (disableAllMessageNotifications && !desktopNotifications && !isHighlighted && !hasMentionToUser && !hasReplyToThread) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusConnection === 'offline' || status === 'busy' || desktopNotifications === 'nothing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!desktopNotifications) {
|
||||
if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'all' && (!isThread || hasReplyToThread)) {
|
||||
return true;
|
||||
}
|
||||
if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'nothing') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
(roomType === 'd' ||
|
||||
(!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) ||
|
||||
isHighlighted ||
|
||||
desktopNotifications === 'all' ||
|
||||
hasMentionToUser) &&
|
||||
(isHighlighted || !isThread || hasReplyToThread)
|
||||
);
|
||||
}
|
||||
242
apps/meteor/server/lib/notifications/message/email.js
Normal file
242
apps/meteor/server/lib/notifications/message/email.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { ltrim } from '../../../../lib/utils/stringUtils';
|
||||
import { settings } from '../../../settings';
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { i18n } from '../../i18n';
|
||||
import { metrics } from '../../metrics';
|
||||
import { roomCoordinator } from '../../rooms/roomCoordinator';
|
||||
import { getURL } from '../../utils/getURL';
|
||||
import * as Mailer from '../email/api';
|
||||
|
||||
let advice = '';
|
||||
let goToMessage = '';
|
||||
Meteor.startup(() => {
|
||||
settings.watch('email_style', () => {
|
||||
goToMessage = Mailer.inlinecss('<p><a class=\'btn\' href="[room_path]">{Offline_Link_Message}</a></p>');
|
||||
});
|
||||
Mailer.getTemplate('Email_Footer_Direct_Reply', (value) => {
|
||||
advice = value;
|
||||
});
|
||||
});
|
||||
|
||||
export async function getEmailContent({ message, user, room }) {
|
||||
const lng = (user && user.language) || settings.get('Language') || 'en';
|
||||
|
||||
const roomName = escapeHTML(`#${await roomCoordinator.getRoomName(room.t, room)}`);
|
||||
const userName = escapeHTML(settings.get('UI_Use_Real_Name') ? message.u.name || message.u.username : message.u.username);
|
||||
|
||||
const roomDirectives = roomCoordinator.getRoomDirectives(room.t);
|
||||
|
||||
const files = (message.files || [message.file]).filter((file) => file && file.typeGroup !== 'thumb');
|
||||
const hasFiles = files.length > 0;
|
||||
const hasText = typeof message.msg === 'string' && message.msg.trim() !== '';
|
||||
const isGroupChat = roomDirectives.isGroupChat(room);
|
||||
|
||||
let header;
|
||||
if (hasFiles && !hasText) {
|
||||
// file-only message
|
||||
const isMultipleFiles = files.length > 1;
|
||||
let headerKey;
|
||||
if (isGroupChat) {
|
||||
headerKey = isMultipleFiles ? 'User_uploaded_files_on_channel' : 'User_uploaded_a_file_on_channel';
|
||||
} else {
|
||||
headerKey = isMultipleFiles ? 'User_uploaded_files_to_you' : 'User_uploaded_a_file_to_you';
|
||||
}
|
||||
header = i18n.t(headerKey, { username: userName, channel: roomName, count: files.length, lng });
|
||||
} else {
|
||||
// text message (with or without files)
|
||||
header = i18n.t(!isGroupChat ? 'User_sent_a_message_to_you' : 'User_sent_a_message_on_channel', {
|
||||
username: userName,
|
||||
channel: roomName,
|
||||
lng,
|
||||
});
|
||||
}
|
||||
|
||||
if (message.t === 'e2e') {
|
||||
return settings.get('Email_notification_show_message') ? i18n.t('Encrypted_message_preview_unavailable', { lng }) : header;
|
||||
}
|
||||
|
||||
if (!settings.get('Email_notification_show_message')) {
|
||||
return header;
|
||||
}
|
||||
|
||||
const contentParts = [];
|
||||
|
||||
if (hasText) {
|
||||
let messageContent = escapeHTML(message.msg);
|
||||
message = await callbacks.run('renderMessage', message);
|
||||
if (message.tokens && message.tokens.length > 0) {
|
||||
message.tokens.forEach((token) => {
|
||||
token.text = token.text.replace(/([^\$])(\$[^\$])/gm, '$1$$$2');
|
||||
messageContent = messageContent.replace(token.token, token.text);
|
||||
});
|
||||
}
|
||||
contentParts.push(messageContent.replace(/\n/gm, '<br/>'));
|
||||
}
|
||||
|
||||
if (hasFiles) {
|
||||
const attachments = message.attachments || [];
|
||||
const fileParts = files.map((file, index) => {
|
||||
let part = escapeHTML(file.name);
|
||||
if (attachments[index]?.description) {
|
||||
part += `<br/><br/>${escapeHTML(attachments[index].description)}`;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
contentParts.push(fileParts.join('<br/><br/>'));
|
||||
}
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
return `${header}:<br/><br/>${contentParts.join('<br/><br/>')}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
||||
const [attachment] = message.attachments;
|
||||
|
||||
let content = '';
|
||||
|
||||
if (attachment.title) {
|
||||
content += `${escapeHTML(attachment.title)}<br/>`;
|
||||
}
|
||||
if (attachment.text) {
|
||||
content += `${escapeHTML(attachment.text)}<br/>`;
|
||||
}
|
||||
|
||||
return `${header}:<br/><br/>${content}`;
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
const getButtonUrl = (room, subscription, message) => {
|
||||
const basePath = roomCoordinator.getRouteLink(room.t, subscription).replace(Meteor.absoluteUrl(), '');
|
||||
|
||||
const path = `${ltrim(basePath, '/')}?msg=${message._id}`;
|
||||
return getURL(
|
||||
path,
|
||||
{
|
||||
full: true,
|
||||
cloud: settings.get('Offline_Message_Use_DeepLink'),
|
||||
cloud_route: 'room',
|
||||
cloud_params: {
|
||||
rid: room._id,
|
||||
mid: message._id,
|
||||
},
|
||||
},
|
||||
settings.get('DeepLink_Url'),
|
||||
);
|
||||
};
|
||||
|
||||
function generateNameEmail(name, email) {
|
||||
return `${String(name).replace(/@/g, '%40').replace(/[<>,]/g, '')} <${email}>`;
|
||||
}
|
||||
|
||||
export async function getEmailData({ message, receiver, sender, subscription, room, emailAddress, hasMentionToUser }) {
|
||||
const username = settings.get('UI_Use_Real_Name') ? message.u.name || message.u.username : message.u.username;
|
||||
let subjectKey = 'Offline_Mention_All_Email';
|
||||
|
||||
if (!roomCoordinator.getRoomDirectives(room.t).isGroupChat(room)) {
|
||||
subjectKey = 'Offline_DM_Email';
|
||||
} else if (hasMentionToUser) {
|
||||
subjectKey = 'Offline_Mention_Email';
|
||||
}
|
||||
|
||||
const emailSubject = Mailer.replace(settings.get(subjectKey), {
|
||||
user: username,
|
||||
room: await roomCoordinator.getRoomName(room.t, room),
|
||||
});
|
||||
const content = await getEmailContent({
|
||||
message,
|
||||
user: receiver,
|
||||
room,
|
||||
});
|
||||
|
||||
const room_path = getButtonUrl(room, subscription, message);
|
||||
|
||||
const receiverName = settings.get('UI_Use_Real_Name') ? receiver.name || receiver.username : receiver.username;
|
||||
|
||||
const email = {
|
||||
from: generateNameEmail(username, settings.get('From_Email')),
|
||||
to: generateNameEmail(receiverName, emailAddress),
|
||||
subject: emailSubject,
|
||||
html: content + goToMessage + (settings.get('Direct_Reply_Enable') ? advice : ''),
|
||||
data: {
|
||||
room_path,
|
||||
},
|
||||
headers: {},
|
||||
};
|
||||
|
||||
if (sender.emails?.length > 0 && settings.get('Add_Sender_To_ReplyTo')) {
|
||||
const [senderEmail] = sender.emails;
|
||||
email.headers['Reply-To'] = generateNameEmail(username, senderEmail.address);
|
||||
}
|
||||
|
||||
// If direct reply enabled, email content with headers
|
||||
if (settings.get('Direct_Reply_Enable')) {
|
||||
const replyto = settings.get('Direct_Reply_ReplyTo') || settings.get('Direct_Reply_Username');
|
||||
|
||||
// Reply-To header with format "username+messageId@domain"
|
||||
email.headers['Reply-To'] = `${replyto.split('@')[0].split(settings.get('Direct_Reply_Separator'))[0]}${settings.get(
|
||||
'Direct_Reply_Separator',
|
||||
)}${message.tmid || message._id}@${replyto.split('@')[1]}`;
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
export function sendEmailFromData(data) {
|
||||
return Mailer.send(data).then(() => {
|
||||
metrics.notificationsSent.inc({ notification_type: 'email' });
|
||||
metrics.notificationsSentTotal.inc({ notification_type: 'email' });
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldNotifyEmail({
|
||||
disableAllMessageNotifications,
|
||||
statusConnection,
|
||||
emailNotifications,
|
||||
isHighlighted,
|
||||
hasMentionToUser,
|
||||
hasMentionToAll,
|
||||
hasReplyToThread,
|
||||
roomType,
|
||||
isThread,
|
||||
}) {
|
||||
// email notifications are disabled globally
|
||||
if (!settings.get('Accounts_AllowEmailNotifications')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// user/room preference to nothing
|
||||
if (emailNotifications === 'nothing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// user connected (don't need to send him an email)
|
||||
if (statusConnection === 'online') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// no user or room preference
|
||||
if (emailNotifications == null) {
|
||||
if (disableAllMessageNotifications && !isHighlighted && !hasMentionToUser && !hasReplyToThread) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// default server preference is disabled
|
||||
if (settings.get('Accounts_Default_User_Preferences_emailNotificationMode') === 'nothing') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
(roomType === 'd' ||
|
||||
isHighlighted ||
|
||||
emailNotifications === 'all' ||
|
||||
hasMentionToUser ||
|
||||
(!disableAllMessageNotifications && hasMentionToAll)) &&
|
||||
(isHighlighted || !isThread || hasReplyToThread)
|
||||
);
|
||||
}
|
||||
49
apps/meteor/server/lib/notifications/message/index.ts
Normal file
49
apps/meteor/server/lib/notifications/message/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { IMessage, IUser } from '@rocket.chat/core-typings';
|
||||
import { isFileAttachment, isFileImageAttachment } from '@rocket.chat/core-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { i18n } from '../../i18n';
|
||||
|
||||
/**
|
||||
* This function returns a string ready to be shown in the notification
|
||||
*
|
||||
* @param {object} message the message to be parsed
|
||||
*/
|
||||
export async function parseMessageTextPerUser(
|
||||
messageText: string,
|
||||
message: Pick<IMessage, 'u' | 'msg' | 't' | 'attachments'>,
|
||||
receiver: Pick<IUser, 'language'>,
|
||||
): Promise<string> {
|
||||
const lng = receiver.language || settings.get('Language') || 'en';
|
||||
|
||||
const firstAttachment = message.attachments?.[0];
|
||||
if (!message.msg && firstAttachment && isFileAttachment(firstAttachment) && isFileImageAttachment(firstAttachment)) {
|
||||
return firstAttachment.image_type ? i18n.t('User_uploaded_image', { lng }) : i18n.t('User_uploaded_file', { lng });
|
||||
}
|
||||
|
||||
if (message.msg && message.t === 'e2e') {
|
||||
return i18n.t('Encrypted_message', { lng });
|
||||
}
|
||||
|
||||
// perform processing required before sending message as notification such as markdown filtering
|
||||
return callbacks.run('renderNotification', messageText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces @username with full name
|
||||
*
|
||||
* @param {string} message The message to replace
|
||||
* @param {object[]} mentions Array of mentions used to make replacements
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replaceMentionedUsernamesWithFullNames(message: string, mentions: NonNullable<IMessage['mentions']>): string {
|
||||
mentions.forEach((mention) => {
|
||||
if (mention.name) {
|
||||
message = message.replace(new RegExp(escapeRegExp(`@${mention.username}`), 'g'), mention.name);
|
||||
}
|
||||
});
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
/**
|
||||
* Checks if a message contains a user highlight
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {array|undefined} highlights
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function messageContainsHighlight(message: Pick<IMessage, 'msg'>, highlights: string[] | undefined): boolean {
|
||||
if (!highlights || highlights.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const leftBoundary = '(?<=^|[^\\p{L}\\p{N}_])';
|
||||
const rightBoundary = '(?=$|[^\\p{L}\\p{N}_])';
|
||||
|
||||
return highlights.some((highlight: string) => {
|
||||
// Due to unnecessary escaping in escapeRegExp, we need to remove the escape character for the following characters: - = ! :
|
||||
// This is necessary because it was crashing the client due to Invalid regular expression error.
|
||||
const hl = escapeRegExp(highlight).replace(/\\([-=!:])/g, '$1');
|
||||
const pattern =
|
||||
`(?<!:)${leftBoundary}${hl}${rightBoundary}:` +
|
||||
`|:${leftBoundary}${hl}${rightBoundary}(?!:)` +
|
||||
`|${leftBoundary}(?<!:)${hl}(?!:)${rightBoundary}`;
|
||||
const regexp = new RegExp(pattern, 'iu');
|
||||
return regexp.test(message.msg);
|
||||
});
|
||||
}
|
||||
120
apps/meteor/server/lib/notifications/message/mobile.js
Normal file
120
apps/meteor/server/lib/notifications/message/mobile.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Subscriptions } from '@rocket.chat/models';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { i18n } from '../../i18n';
|
||||
import { isRoomCompatibleWithVideoConfRinging } from '../../isRoomCompatibleWithVideoConfRinging';
|
||||
import { roomCoordinator } from '../../rooms/roomCoordinator';
|
||||
|
||||
const CATEGORY_MESSAGE = 'MESSAGE';
|
||||
const CATEGORY_MESSAGE_NOREPLY = 'MESSAGE_NOREPLY';
|
||||
|
||||
function enableNotificationReplyButton(room, username) {
|
||||
// Some users may have permission to send messages even on readonly rooms, but we're ok with false negatives here in exchange of better perfomance
|
||||
if (room.ro === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!room.muted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !room.muted.includes(username);
|
||||
}
|
||||
|
||||
export async function getPushData({
|
||||
room,
|
||||
message,
|
||||
userId,
|
||||
senderUsername,
|
||||
senderName,
|
||||
notificationMessage,
|
||||
receiver,
|
||||
shouldOmitMessage = true,
|
||||
}) {
|
||||
const username = settings.get('Push_show_username_room') ? (settings.get('UI_Use_Real_Name') && senderName) || senderUsername : '';
|
||||
|
||||
const lng = receiver.language || settings.get('Language') || 'en';
|
||||
|
||||
let messageText;
|
||||
if (shouldOmitMessage && settings.get('Push_request_content_from_server')) {
|
||||
messageText = i18n.t('You_have_a_new_message', { lng });
|
||||
} else if (!settings.get('Push_show_message')) {
|
||||
messageText = i18n.t('You_have_a_new_message', { lng });
|
||||
} else {
|
||||
messageText = notificationMessage;
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
sender: message.u,
|
||||
senderName: username,
|
||||
type: room.t,
|
||||
name: settings.get('Push_show_username_room') ? room.name : '',
|
||||
messageType: message.t,
|
||||
tmid: message.tmid,
|
||||
...(message.t === 'e2e' && { msg: message.msg, content: message.content }),
|
||||
},
|
||||
roomName:
|
||||
settings.get('Push_show_username_room') && roomCoordinator.getRoomDirectives(room.t).isGroupChat(room)
|
||||
? `#${await roomCoordinator.getRoomName(room.t, room, userId)}`
|
||||
: '',
|
||||
username,
|
||||
message: messageText,
|
||||
badge: await Subscriptions.getBadgeCount(userId),
|
||||
category: enableNotificationReplyButton(room, receiver.username) ? CATEGORY_MESSAGE : CATEGORY_MESSAGE_NOREPLY,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldNotifyMobile({
|
||||
disableAllMessageNotifications,
|
||||
mobilePushNotifications,
|
||||
hasMentionToAll,
|
||||
isHighlighted,
|
||||
hasMentionToUser,
|
||||
hasReplyToThread,
|
||||
roomType,
|
||||
isThread,
|
||||
isVideoConf,
|
||||
userPreferences,
|
||||
roomUids,
|
||||
}) {
|
||||
if (settings.get('Push_enable') !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (disableAllMessageNotifications && mobilePushNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mobilePushNotifications === 'nothing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the user is going to receive a ringing push notification, do not send another push for the message generated by that videoconference
|
||||
if (
|
||||
isVideoConf &&
|
||||
settings.get('VideoConf_Mobile_Ringing') &&
|
||||
isRoomCompatibleWithVideoConfRinging(roomType, roomUids) &&
|
||||
(userPreferences?.enableMobileRinging ?? settings.get(`Accounts_Default_User_Preferences_enableMobileRinging`))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mobilePushNotifications) {
|
||||
if (settings.get('Accounts_Default_User_Preferences_pushNotifications') === 'all' && (!isThread || hasReplyToThread)) {
|
||||
return true;
|
||||
}
|
||||
if (settings.get('Accounts_Default_User_Preferences_pushNotifications') === 'nothing') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
(roomType === 'd' ||
|
||||
(!disableAllMessageNotifications && hasMentionToAll) ||
|
||||
isHighlighted ||
|
||||
mobilePushNotifications === 'all' ||
|
||||
hasMentionToUser) &&
|
||||
(isHighlighted || !isThread || hasReplyToThread)
|
||||
);
|
||||
}
|
||||
129
apps/meteor/server/lib/notifications/processDirectEmail.ts
Normal file
129
apps/meteor/server/lib/notifications/processDirectEmail.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { Messages, Subscriptions, Users, Rooms } from '@rocket.chat/models';
|
||||
import type { ParsedMail } from 'mailparser';
|
||||
import moment from 'moment';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
import { canAccessRoomAsync } from '../authorization';
|
||||
import { hasPermissionAsync } from '../authorization/hasPermission';
|
||||
import { sendMessage } from '../messages/sendMessage';
|
||||
import { metrics } from '../metrics';
|
||||
|
||||
const isParsedEmail = (email: ParsedMail): email is Required<ParsedMail> => 'date' in email && 'html' in email;
|
||||
|
||||
export const processDirectEmail = async function (email: ParsedMail): Promise<void> {
|
||||
if (!isParsedEmail(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const to = Array.isArray(email.to) ? email.to.find((email) => email.text) : email.to;
|
||||
const mid = to?.text.split('@')[0].split('+')[1];
|
||||
|
||||
if (!mid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = new Date(email.date);
|
||||
|
||||
const tsDiff = Math.abs(moment(ts).diff(new Date()));
|
||||
|
||||
let msg = email.text.split('\n\n').join('\n');
|
||||
|
||||
if (msg && msg.length > (settings.get('Message_MaxAllowedSize') as number)) {
|
||||
return;
|
||||
}
|
||||
const emailAdress = email.from.value[0].address;
|
||||
if (!emailAdress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await Users.findOneByEmailAddress(emailAdress, {
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.username) {
|
||||
// user not found
|
||||
return;
|
||||
}
|
||||
|
||||
const prevMessage = await Messages.findOneById(mid, {
|
||||
projection: {
|
||||
rid: 1,
|
||||
u: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!prevMessage) {
|
||||
// message doesn't exist anymore
|
||||
return;
|
||||
}
|
||||
|
||||
const roomInfo = await Rooms.findOneById(prevMessage.rid);
|
||||
|
||||
if (!roomInfo) {
|
||||
// room doesn't exist anymore
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await canAccessRoomAsync(roomInfo, user);
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check mention
|
||||
if (msg.indexOf(`@${prevMessage.u.username}`) === -1 && roomInfo.t !== 'd') {
|
||||
msg = `@${prevMessage.u.username} ${msg}`;
|
||||
}
|
||||
|
||||
// reply message link WE HA THREADS KNOW DONT NEED TO QUOTE THE previous message
|
||||
// let prevMessageLink = `[ ](${ Meteor.absoluteUrl().replace(/\/$/, '') }`;
|
||||
// if (roomInfo.t === 'c') {
|
||||
// prevMessageLink += `/channel/${ roomInfo.name }?msg=${ mid }) `;
|
||||
// } else if (roomInfo.t === 'd') {
|
||||
// prevMessageLink += `/direct/${ prevMessage.u.username }?msg=${ mid }) `;
|
||||
// } else if (roomInfo.t === 'p') {
|
||||
// prevMessageLink += `/group/${ roomInfo.name }?msg=${ mid }) `;
|
||||
// }
|
||||
// // add reply message link
|
||||
// msg = prevMessageLink + msg;
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(prevMessage.rid, user._id);
|
||||
if (subscription && (subscription.blocked || subscription.blocker)) {
|
||||
// room is blocked
|
||||
return;
|
||||
}
|
||||
|
||||
if ((roomInfo.muted || []).includes(user.username || '')) {
|
||||
// user is muted
|
||||
return;
|
||||
}
|
||||
|
||||
// room is readonly
|
||||
if (roomInfo.ro === true) {
|
||||
if (!(await hasPermissionAsync(user._id, 'post-readonly', roomInfo._id))) {
|
||||
// Check if the user was manually unmuted
|
||||
if (!(roomInfo.unmuted || []).includes(user.username || '')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message: Pick<IMessage, 'ts' | 'msg' | 'groupable' | 'rid' | 'sentByEmail' | 'tmid'> = {
|
||||
ts: tsDiff < 10000 ? ts : new Date(),
|
||||
msg,
|
||||
sentByEmail: true,
|
||||
groupable: false,
|
||||
tmid: mid,
|
||||
rid: prevMessage.rid,
|
||||
};
|
||||
|
||||
const result = await sendMessage(user, message, roomInfo);
|
||||
|
||||
metrics.messagesSent.inc();
|
||||
metrics.messagesSentTotal.inc();
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import PushNotification from './lib/PushNotification';
|
||||
|
||||
export { PushNotification };
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { IMessage, IPushNotificationConfig, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../../settings';
|
||||
import { callbacks } from '../../../callbacks';
|
||||
import { RocketChatAssets } from '../../../media/assets';
|
||||
import { metrics } from '../../../metrics';
|
||||
import { replaceMentionedUsernamesWithFullNames, parseMessageTextPerUser } from '../../message';
|
||||
import { getPushData } from '../../message/mobile';
|
||||
import { Push } from '../../push';
|
||||
|
||||
type PushNotificationData = {
|
||||
rid: string;
|
||||
uid: string;
|
||||
mid: string;
|
||||
roomName: string;
|
||||
username: string;
|
||||
message: string;
|
||||
payload: Record<string, any>;
|
||||
badge: number;
|
||||
category: string;
|
||||
};
|
||||
|
||||
type GetNotificationConfigParam = PushNotificationData & {
|
||||
idOnly: boolean;
|
||||
};
|
||||
|
||||
type NotificationPayload = {
|
||||
message: IMessage;
|
||||
notification: IPushNotificationConfig;
|
||||
};
|
||||
|
||||
function hash(str: string): number {
|
||||
let hash = 0;
|
||||
let i = str.length;
|
||||
|
||||
while (i) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(--i);
|
||||
hash &= hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
class PushNotification {
|
||||
getNotificationId(roomId: string): number {
|
||||
const serverId = settings.get('uniqueID');
|
||||
return hash(`${serverId}|${roomId}`); // hash
|
||||
}
|
||||
|
||||
private getNotificationConfig({
|
||||
rid,
|
||||
uid: userId,
|
||||
mid: messageId,
|
||||
roomName,
|
||||
username,
|
||||
message,
|
||||
payload,
|
||||
badge = 1,
|
||||
category,
|
||||
idOnly = false,
|
||||
}: GetNotificationConfigParam): IPushNotificationConfig {
|
||||
const title = idOnly ? '' : roomName || username;
|
||||
|
||||
// message is being redacted already by 'getPushData' if idOnly is true
|
||||
const text = !idOnly && roomName !== '' ? `${username}: ${message}` : message;
|
||||
|
||||
return {
|
||||
from: 'push',
|
||||
badge,
|
||||
sound: 'default',
|
||||
priority: 10,
|
||||
title,
|
||||
text,
|
||||
payload: {
|
||||
host: Meteor.absoluteUrl(),
|
||||
messageId,
|
||||
notificationType: idOnly ? 'message-id-only' : 'message',
|
||||
...(!idOnly && { rid, ...payload }),
|
||||
},
|
||||
userId,
|
||||
notId: this.getNotificationId(rid),
|
||||
gcm: {
|
||||
style: 'inbox',
|
||||
image: RocketChatAssets.getURL('Assets_favicon_192'),
|
||||
},
|
||||
...(category !== '' ? { apn: { category } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async send({ rid, uid, mid, roomName, username, message, payload, badge = 1, category }: PushNotificationData): Promise<void> {
|
||||
const idOnly = settings.get<boolean>('Push_request_content_from_server');
|
||||
const config = this.getNotificationConfig({
|
||||
rid,
|
||||
uid,
|
||||
mid,
|
||||
roomName,
|
||||
username,
|
||||
message,
|
||||
payload,
|
||||
badge,
|
||||
category,
|
||||
idOnly,
|
||||
});
|
||||
|
||||
await Push.send(config);
|
||||
|
||||
metrics.notificationsSent.inc({ notification_type: 'mobile' });
|
||||
metrics.notificationsSentTotal.inc({ notification_type: 'mobile' });
|
||||
}
|
||||
|
||||
async getNotificationForMessageId({
|
||||
receiver,
|
||||
message,
|
||||
room,
|
||||
}: {
|
||||
receiver: IUser;
|
||||
message: IMessage;
|
||||
room: IRoom;
|
||||
}): Promise<NotificationPayload> {
|
||||
const sender = await Users.findOneById(message.u._id, { projection: { username: 1, name: 1 } });
|
||||
if (!sender) {
|
||||
throw new Error('Message sender not found');
|
||||
}
|
||||
|
||||
let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg);
|
||||
if (message.mentions && Object.keys(message.mentions).length > 0 && settings.get('UI_Use_Real_Name')) {
|
||||
notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions);
|
||||
}
|
||||
notificationMessage = await parseMessageTextPerUser(notificationMessage, message, receiver);
|
||||
|
||||
const pushData = await getPushData({
|
||||
room,
|
||||
message,
|
||||
userId: receiver._id,
|
||||
receiver,
|
||||
senderUsername: sender.username,
|
||||
senderName: sender.name,
|
||||
notificationMessage,
|
||||
shouldOmitMessage: false,
|
||||
});
|
||||
|
||||
return {
|
||||
message,
|
||||
notification: this.getNotificationConfig({
|
||||
...pushData,
|
||||
rid: message.rid,
|
||||
uid: message.u._id,
|
||||
mid: message._id,
|
||||
idOnly: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new PushNotification();
|
||||
163
apps/meteor/server/lib/notifications/push/apn.spec.ts
Normal file
163
apps/meteor/server/lib/notifications/push/apn.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { expect } from 'chai';
|
||||
import { describe, it, beforeEach } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
const mocks = {
|
||||
logger: {
|
||||
debug: sandbox.stub(),
|
||||
warn: sandbox.stub(),
|
||||
error: sandbox.stub(),
|
||||
},
|
||||
ApnProvider: sandbox.stub(),
|
||||
};
|
||||
|
||||
const apnMock = {
|
||||
'Provider': mocks.ApnProvider,
|
||||
'Notification': sandbox.stub(),
|
||||
'@noCallThru': true,
|
||||
};
|
||||
|
||||
const { initAPN, shutdownAPN } = proxyquire.noCallThru().load('./apn', {
|
||||
'@parse/node-apn': {
|
||||
default: apnMock,
|
||||
...apnMock,
|
||||
},
|
||||
'./logger': { logger: mocks.logger },
|
||||
});
|
||||
|
||||
const baseOptions = {
|
||||
apn: {
|
||||
cert: 'cert-data',
|
||||
key: 'key-data',
|
||||
gateway: undefined as string | undefined,
|
||||
},
|
||||
production: false,
|
||||
};
|
||||
|
||||
const buildOptions = (overrides: Record<string, unknown> = {}, apnOverrides: Record<string, unknown> = {}) => ({
|
||||
...baseOptions,
|
||||
...overrides,
|
||||
apn: {
|
||||
...baseOptions.apn,
|
||||
...apnOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
describe('initAPN', () => {
|
||||
beforeEach(() => {
|
||||
sandbox.resetHistory();
|
||||
mocks.ApnProvider.reset();
|
||||
});
|
||||
|
||||
describe('APN provider initialization', () => {
|
||||
it('should create apn.Provider with correct options', () => {
|
||||
const options = buildOptions({ production: true }, { gateway: 'gateway.push.apple.com' });
|
||||
|
||||
initAPN({
|
||||
options,
|
||||
absoluteUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(mocks.ApnProvider.calledWithNew()).to.be.true;
|
||||
});
|
||||
|
||||
it('should pass production flag from options to Provider', () => {
|
||||
initAPN({
|
||||
options: buildOptions({ production: true }),
|
||||
absoluteUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', true);
|
||||
});
|
||||
|
||||
it('should pass production false when options.production is false', () => {
|
||||
initAPN({
|
||||
options: buildOptions({ production: false }),
|
||||
absoluteUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', false);
|
||||
});
|
||||
|
||||
it('should pass cert and key to Provider', () => {
|
||||
initAPN({
|
||||
options: buildOptions({}, { cert: 'my-cert', key: 'my-key' }),
|
||||
absoluteUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const providerArgs = mocks.ApnProvider.firstCall.args[0];
|
||||
expect(providerArgs).to.have.property('cert', 'my-cert');
|
||||
expect(providerArgs).to.have.property('key', 'my-key');
|
||||
});
|
||||
|
||||
it('should pass gateway to Provider when specified', () => {
|
||||
initAPN({
|
||||
options: buildOptions({}, { gateway: 'gateway.push.apple.com' }),
|
||||
absoluteUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('gateway', 'gateway.push.apple.com');
|
||||
});
|
||||
|
||||
it('should spread all apn options to Provider', () => {
|
||||
initAPN({
|
||||
options: buildOptions({ production: true }, { cert: 'c', key: 'k', gateway: 'gateway.sandbox.push.apple.com' }),
|
||||
absoluteUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const providerArgs = mocks.ApnProvider.firstCall.args[0];
|
||||
expect(providerArgs).to.deep.equal({
|
||||
cert: 'c',
|
||||
key: 'k',
|
||||
gateway: 'gateway.sandbox.push.apple.com',
|
||||
production: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw when Provider constructor throws', () => {
|
||||
mocks.ApnProvider.throws(new Error('APN init failed'));
|
||||
|
||||
expect(() =>
|
||||
initAPN({
|
||||
options: buildOptions(),
|
||||
absoluteUrl: 'https://example.com',
|
||||
}),
|
||||
).to.not.throw();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdownAPN', () => {
|
||||
beforeEach(() => {
|
||||
sandbox.resetHistory();
|
||||
mocks.ApnProvider.reset();
|
||||
});
|
||||
|
||||
it('should shut down the active provider', async () => {
|
||||
const shutdown = sandbox.stub().resolves();
|
||||
mocks.ApnProvider.returns({ shutdown });
|
||||
|
||||
initAPN({ options: buildOptions(), absoluteUrl: 'https://example.com' });
|
||||
await shutdownAPN();
|
||||
|
||||
expect(shutdown.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should resolve when no provider was initialized', async () => {
|
||||
await shutdownAPN();
|
||||
await shutdownAPN();
|
||||
});
|
||||
|
||||
it('should not reject when provider shutdown fails', async () => {
|
||||
const shutdown = sandbox.stub().rejects(new Error('shutdown failed'));
|
||||
mocks.ApnProvider.returns({ shutdown });
|
||||
|
||||
initAPN({ options: buildOptions(), absoluteUrl: 'https://example.com' });
|
||||
await shutdownAPN();
|
||||
|
||||
expect(mocks.logger.error.called).to.be.true;
|
||||
});
|
||||
});
|
||||
172
apps/meteor/server/lib/notifications/push/apn.ts
Normal file
172
apps/meteor/server/lib/notifications/push/apn.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import apn from '@parse/node-apn';
|
||||
import type { RequiredField } from '@rocket.chat/core-typings';
|
||||
import EJSON from 'ejson';
|
||||
|
||||
import type { PushOptions, PendingPushNotification } from './definition';
|
||||
import { logger } from './logger';
|
||||
|
||||
let apnConnection: apn.Provider | undefined;
|
||||
|
||||
declare module '@parse/node-apn' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Notification {
|
||||
setContentAvailable: (value: boolean | 1 | 0) => void;
|
||||
set category(_value: string | undefined);
|
||||
set body(_value: string);
|
||||
set title(_value: string);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendAPN = ({
|
||||
userToken,
|
||||
notification,
|
||||
_removeToken,
|
||||
}: {
|
||||
userToken: string;
|
||||
notification: PendingPushNotification & { topic: string };
|
||||
_removeToken: (token: string) => void;
|
||||
}) => {
|
||||
if (!apnConnection) {
|
||||
throw new Error('Apn Connection not initialized.');
|
||||
}
|
||||
|
||||
const priority = notification.priority || notification.priority === 0 ? notification.priority : 10;
|
||||
|
||||
const note = new apn.Notification();
|
||||
|
||||
// Expires 1 hour from now, unless configured otherwise.
|
||||
const expirationSeconds = notification.apn?.expirationSeconds ?? 3600;
|
||||
|
||||
if (notification.useVoipToken) {
|
||||
note.pushType = 'voip';
|
||||
}
|
||||
|
||||
note.expiry = Math.floor(Date.now() / 1000) + expirationSeconds;
|
||||
|
||||
if (notification.badge !== undefined) {
|
||||
note.badge = notification.badge;
|
||||
}
|
||||
|
||||
if (notification.sound !== undefined) {
|
||||
note.sound = notification.sound;
|
||||
}
|
||||
|
||||
if (notification.contentAvailable != null) {
|
||||
note.setContentAvailable(notification.contentAvailable);
|
||||
}
|
||||
|
||||
// adds category support for iOS8 custom actions as described here:
|
||||
// https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/
|
||||
// RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36
|
||||
if (notification.apn?.category) {
|
||||
note.category = notification.apn.category;
|
||||
}
|
||||
|
||||
if (notification.text) {
|
||||
note.body = notification.text;
|
||||
}
|
||||
if (notification.title) {
|
||||
note.title = notification.title;
|
||||
}
|
||||
|
||||
if (notification.notId != null) {
|
||||
note.threadId = String(notification.notId);
|
||||
}
|
||||
|
||||
// Allow the user to set payload data
|
||||
note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
|
||||
|
||||
if (notification.from) {
|
||||
note.payload.messageFrom = notification.from;
|
||||
}
|
||||
note.priority = priority;
|
||||
|
||||
note.topic = notification.topic;
|
||||
note.mutableContent = true;
|
||||
|
||||
void apnConnection.send(note, userToken).then((response) => {
|
||||
response.failed.forEach((failure) => {
|
||||
logger.debug({
|
||||
msg: 'Got error code for APN token',
|
||||
status: failure.status,
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
if (['400', '410'].includes(String(failure.status))) {
|
||||
logger.debug({
|
||||
msg: 'Removing APN token',
|
||||
token: userToken,
|
||||
});
|
||||
_removeToken(userToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const shutdownAPN = async () => {
|
||||
const connection = apnConnection;
|
||||
apnConnection = undefined;
|
||||
|
||||
try {
|
||||
await connection?.shutdown();
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error shutting down APN connection', err });
|
||||
}
|
||||
};
|
||||
|
||||
export const initAPN = ({ options, absoluteUrl }: { options: RequiredField<PushOptions, 'apn'>; absoluteUrl: string }) => {
|
||||
logger.debug('APN configured');
|
||||
|
||||
if (options.apn.gateway) {
|
||||
// We check the apn gateway i the options, we could risk shipping
|
||||
// server into production while using the production configuration.
|
||||
// On the other hand we could be in development but using the production
|
||||
// configuration. And finally we could have configured an unknown apn
|
||||
// gateway (this could change in the future - but a warning about typos
|
||||
// can save hours of debugging)
|
||||
//
|
||||
// Warn about gateway configurations - it's more a guide
|
||||
|
||||
if (options.apn.gateway === 'gateway.sandbox.push.apple.com') {
|
||||
// Using the development sandbox
|
||||
logger.warn('WARNING: Push APN is in development mode');
|
||||
} else if (options.apn.gateway === 'gateway.push.apple.com') {
|
||||
// In production - but warn if we are running on localhost
|
||||
if (/http:\/\/localhost/.test(absoluteUrl)) {
|
||||
logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost');
|
||||
}
|
||||
} else {
|
||||
// Warn about gateways we dont know about
|
||||
logger.warn({
|
||||
msg: 'WARNING: Push APN unknown gateway',
|
||||
gateway: options.apn.gateway,
|
||||
});
|
||||
}
|
||||
} else if (options.production) {
|
||||
if (/http:\/\/localhost/.test(absoluteUrl)) {
|
||||
logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost');
|
||||
}
|
||||
} else {
|
||||
logger.warn('WARNING: Push APN is in development mode');
|
||||
}
|
||||
|
||||
// Check certificate data
|
||||
if (!options.apn.cert?.length) {
|
||||
logger.error('ERROR: Push server could not find cert');
|
||||
}
|
||||
|
||||
// Check key data
|
||||
if (!options.apn.key?.length) {
|
||||
logger.error('ERROR: Push server could not find key');
|
||||
}
|
||||
|
||||
// Rig apn connection
|
||||
try {
|
||||
apnConnection = new apn.Provider({
|
||||
...options.apn,
|
||||
production: options.production,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error trying to initialize APN', err });
|
||||
}
|
||||
};
|
||||
47
apps/meteor/server/lib/notifications/push/definition.ts
Normal file
47
apps/meteor/server/lib/notifications/push/definition.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type PushOptions = {
|
||||
sendTimeout?: number;
|
||||
production?: boolean;
|
||||
apn?: {
|
||||
passphrase: string;
|
||||
key: string;
|
||||
cert: string;
|
||||
|
||||
gateway?: string;
|
||||
};
|
||||
gcm?: {
|
||||
apiKey: string;
|
||||
projectNumber: string;
|
||||
};
|
||||
gateways?: string[];
|
||||
uniqueId: string;
|
||||
getAuthorization?: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type PendingPushNotification = {
|
||||
from?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
notId?: number;
|
||||
apn?: {
|
||||
category?: string;
|
||||
expirationSeconds?: number;
|
||||
};
|
||||
gcm?: {
|
||||
style?: string;
|
||||
image?: string;
|
||||
};
|
||||
payload?: Record<string, any>;
|
||||
createdAt: Date;
|
||||
createdBy?: string;
|
||||
|
||||
userId: string;
|
||||
|
||||
sent?: boolean;
|
||||
sending?: number;
|
||||
priority?: number;
|
||||
|
||||
contentAvailable?: 1 | 0;
|
||||
useVoipToken?: boolean;
|
||||
};
|
||||
203
apps/meteor/server/lib/notifications/push/fcm.ts
Normal file
203
apps/meteor/server/lib/notifications/push/fcm.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
|
||||
import EJSON from 'ejson';
|
||||
import type { Response } from 'node-fetch';
|
||||
|
||||
import type { PendingPushNotification } from './definition';
|
||||
import { logger } from './logger';
|
||||
import type { NativeNotificationParameters } from './push';
|
||||
|
||||
type FCMDataField = Record<string, any>;
|
||||
|
||||
type FCMNotificationField = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
type FCMMessage = {
|
||||
notification?: FCMNotificationField;
|
||||
data?: FCMDataField;
|
||||
token?: string;
|
||||
to?: string;
|
||||
android?: {
|
||||
collapseKey?: string;
|
||||
priority?: 'HIGH' | 'NORMAL';
|
||||
ttl?: string;
|
||||
restrictedPackageName?: string;
|
||||
data?: FCMDataField;
|
||||
notification?: FCMNotificationField;
|
||||
fcm_options?: {
|
||||
analytics_label?: string;
|
||||
};
|
||||
direct_boot_ok?: boolean;
|
||||
};
|
||||
webpush?: {
|
||||
headers?: FCMDataField;
|
||||
data?: FCMDataField;
|
||||
notification?: FCMNotificationField;
|
||||
fcm_options?: {
|
||||
link?: string;
|
||||
analytics_label?: string;
|
||||
};
|
||||
};
|
||||
fcm_options?: {
|
||||
analytics_label?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
|
||||
type FCMError = {
|
||||
error: {
|
||||
code: number;
|
||||
message: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
// The 403 error code is used when the server is refusing to process a request to a specific token due to
|
||||
// a SENDER_ID_MISMATCH error. This error is returned when the sender ID provided in the request does not match the sender ID
|
||||
// associated with the registration token.
|
||||
const SENDER_ID_MISMATCH_ERROR_CODE = 403;
|
||||
const NOT_FOUND_ERROR_CODE = 404;
|
||||
|
||||
/**
|
||||
* Send a push notification using Firebase Cloud Messaging (FCM).
|
||||
* implements the Firebase Cloud Messaging HTTP v1 API, and all of its retry logic,
|
||||
* see: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
|
||||
*
|
||||
* Errors:
|
||||
* - For 400, 401 errors: abort, and do not retry.
|
||||
* - For 404 errors: remove the token from the database.
|
||||
* - For 429 errors: retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to 60 seconds.
|
||||
* - For 5xx errors: retry with exponential backoff.
|
||||
*/
|
||||
async function fetchWithRetry(url: string, _removeToken: () => void, options: ExtendedFetchOptions, retries = 0): Promise<Response> {
|
||||
const MAX_RETRIES = 5;
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (retries >= MAX_RETRIES) {
|
||||
logger.error('sendFCM error: max retries reached');
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
||||
if ([SENDER_ID_MISMATCH_ERROR_CODE, NOT_FOUND_ERROR_CODE].includes(response.status)) {
|
||||
_removeToken();
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
|
||||
return fetchWithRetry(url, _removeToken, options, retries + 1);
|
||||
}
|
||||
|
||||
if (response.status >= 500 && response.status < 600) {
|
||||
const backoff = Math.pow(2, retries) * 10000;
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff));
|
||||
return fetchWithRetry(url, _removeToken, options, retries + 1);
|
||||
}
|
||||
|
||||
const error: FCMError = await response.json();
|
||||
logger.error({ msg: 'sendFCM error', err: error });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function getFCMMessagesFromPushData(userTokens: string[], notification: PendingPushNotification): { message: FCMMessage }[] {
|
||||
// first we will get the `data` field from the notification
|
||||
const data: FCMDataField = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
|
||||
|
||||
// Set image
|
||||
if (notification.gcm?.image) {
|
||||
data.image = notification.gcm?.image;
|
||||
}
|
||||
|
||||
// Set extra details
|
||||
if (notification.badge) {
|
||||
data.msgcnt = notification.badge.toString();
|
||||
}
|
||||
|
||||
if (notification.sound) {
|
||||
data.soundname = notification.sound;
|
||||
}
|
||||
|
||||
if (notification.notId) {
|
||||
data.notId = notification.notId.toString();
|
||||
}
|
||||
|
||||
if (notification.gcm?.style) {
|
||||
data.style = notification.gcm?.style;
|
||||
}
|
||||
|
||||
if (notification.contentAvailable) {
|
||||
data['content-available'] = notification.contentAvailable.toString();
|
||||
}
|
||||
|
||||
// then we will create the notification field
|
||||
const notificationField: FCMNotificationField = {
|
||||
...(notification.title && { title: notification.title }),
|
||||
...(notification.text && { body: notification.text }),
|
||||
};
|
||||
|
||||
// then we will create the message
|
||||
const message: FCMMessage = {
|
||||
...(Object.keys(notificationField).length && { notification: notificationField }),
|
||||
data,
|
||||
android: {
|
||||
priority: 'HIGH',
|
||||
},
|
||||
};
|
||||
|
||||
// then we will create the message for each token
|
||||
return userTokens.map((token) => ({ message: { ...message, token } }));
|
||||
}
|
||||
|
||||
export const sendFCM = function ({ userTokens, notification, _removeToken, options }: NativeNotificationParameters): void {
|
||||
const tokens = typeof userTokens === 'string' ? [userTokens] : userTokens;
|
||||
if (!tokens.length) {
|
||||
logger.log('sendFCM no push tokens found');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'sendFCM', tokens, notification });
|
||||
|
||||
const messages = getFCMMessagesFromPushData(tokens, notification);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${options.gcm.apiKey}`,
|
||||
'access_token_auth': true,
|
||||
} as Record<string, any>;
|
||||
|
||||
if (!options.gcm.projectNumber.trim()) {
|
||||
logger.error('sendFCM error: GCM project number is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://fcm.googleapis.com/v1/projects/${options.gcm.projectNumber}/messages:send`;
|
||||
|
||||
for (const fcmRequest of messages) {
|
||||
logger.debug({ msg: 'sendFCM message', request: fcmRequest });
|
||||
|
||||
const removeToken = () => {
|
||||
const { token } = fcmRequest.message;
|
||||
token && _removeToken(token);
|
||||
};
|
||||
|
||||
const response = fetchWithRetry(url, removeToken, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(fcmRequest),
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
response.catch((err) => {
|
||||
logger.error({ msg: 'sendFCM error', err });
|
||||
});
|
||||
}
|
||||
};
|
||||
1
apps/meteor/server/lib/notifications/push/index.ts
Normal file
1
apps/meteor/server/lib/notifications/push/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Push } from './push';
|
||||
3
apps/meteor/server/lib/notifications/push/logger.ts
Normal file
3
apps/meteor/server/lib/notifications/push/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('Push');
|
||||
532
apps/meteor/server/lib/notifications/push/push.ts
Normal file
532
apps/meteor/server/lib/notifications/push/push.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { PushToken } from '@rocket.chat/models';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
import { pick, truncateString } from '@rocket.chat/tools';
|
||||
import { JWT } from 'google-auth-library';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { initAPN, sendAPN, shutdownAPN } from './apn';
|
||||
import type { PushOptions, PendingPushNotification } from './definition';
|
||||
import { sendFCM } from './fcm';
|
||||
import { logger } from './logger';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
|
||||
|
||||
const PUSH_TITLE_LIMIT = 65;
|
||||
const PUSH_MESSAGE_BODY_LIMIT = 240;
|
||||
const PUSH_GATEWAY_MAX_RETRIES = 5;
|
||||
|
||||
type FCMCredentials = {
|
||||
type: string;
|
||||
project_id: string;
|
||||
private_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
client_id: string;
|
||||
auth_uri: string;
|
||||
token_uri: string;
|
||||
auth_provider_x509_cert_url: string;
|
||||
client_x509_cert_url: string;
|
||||
universe_domain: string;
|
||||
};
|
||||
|
||||
export const FCMCredentialsValidationSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
},
|
||||
project_id: {
|
||||
type: 'string',
|
||||
},
|
||||
private_key_id: {
|
||||
type: 'string',
|
||||
},
|
||||
private_key: {
|
||||
type: 'string',
|
||||
},
|
||||
client_email: {
|
||||
type: 'string',
|
||||
},
|
||||
client_id: {
|
||||
type: 'string',
|
||||
},
|
||||
auth_uri: {
|
||||
type: 'string',
|
||||
},
|
||||
token_uri: {
|
||||
type: 'string',
|
||||
},
|
||||
auth_provider_x509_cert_url: {
|
||||
type: 'string',
|
||||
},
|
||||
client_x509_cert_url: {
|
||||
type: 'string',
|
||||
},
|
||||
universe_domain: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['client_email', 'project_id', 'private_key_id', 'private_key'],
|
||||
};
|
||||
|
||||
export const isFCMCredentials = ajv.compile<FCMCredentials>(FCMCredentialsValidationSchema);
|
||||
|
||||
// This type must match the type defined in the push gateway
|
||||
type GatewayNotification = {
|
||||
uniqueId: string;
|
||||
from?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
notId?: number;
|
||||
contentAvailable?: 1 | 0;
|
||||
forceStart?: number;
|
||||
topic?: string;
|
||||
apn?: {
|
||||
from?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
notId?: number;
|
||||
category?: string;
|
||||
expirationSeconds?: number;
|
||||
};
|
||||
gcm?: {
|
||||
from?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
image?: string;
|
||||
style?: string;
|
||||
summaryText?: string;
|
||||
picture?: string;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
notId?: number;
|
||||
actions?: any[];
|
||||
};
|
||||
query?: {
|
||||
userId: any;
|
||||
};
|
||||
token?: IPushToken['token'];
|
||||
tokens?: IPushToken['token'][];
|
||||
payload?: Record<string, any>;
|
||||
delayUntil?: Date;
|
||||
createdAt: Date;
|
||||
createdBy?: string;
|
||||
};
|
||||
|
||||
export type NativeNotificationParameters = {
|
||||
userTokens: string | string[];
|
||||
notification: PendingPushNotification;
|
||||
_removeToken: (token: string) => void;
|
||||
options: RequiredField<PushOptions, 'gcm'>;
|
||||
};
|
||||
|
||||
class PushClass {
|
||||
options: PushOptions = {
|
||||
uniqueId: '',
|
||||
};
|
||||
|
||||
isConfigured = false;
|
||||
|
||||
public configure(options: PushOptions): void {
|
||||
this.options = {
|
||||
sendTimeout: 60000, // Timeout period for notification send
|
||||
...options,
|
||||
};
|
||||
// https://npmjs.org/package/apn
|
||||
|
||||
// After requesting the certificate from Apple, export your private key as
|
||||
// a .p12 file anddownload the .cer file from the iOS Provisioning Portal.
|
||||
|
||||
// gateway.push.apple.com, port 2195
|
||||
// gateway.sandbox.push.apple.com, port 2195
|
||||
|
||||
// Now, in the directory containing cert.cer and key.p12 execute the
|
||||
// following commands to generate your .pem files:
|
||||
// $ openssl x509 -in cert.cer -inform DER -outform PEM -out cert.pem
|
||||
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
|
||||
|
||||
// Block multiple calls
|
||||
if (this.isConfigured) {
|
||||
throw new Error('Configure should not be called more than once!');
|
||||
}
|
||||
|
||||
this.isConfigured = true;
|
||||
|
||||
logger.debug({ msg: 'Configure', options: this.options });
|
||||
|
||||
if (this.options.apn) {
|
||||
initAPN({ options: this.options as RequiredField<PushOptions, 'apn'>, absoluteUrl: Meteor.absoluteUrl() });
|
||||
}
|
||||
}
|
||||
|
||||
public async unconfigure(): Promise<void> {
|
||||
logger.info('Push service disabled: shutting down existing connections');
|
||||
this.isConfigured = false;
|
||||
return shutdownAPN();
|
||||
}
|
||||
|
||||
private removeToken(token: string): void {
|
||||
void PushToken.removeOrUnsetByTokenString(token).catch((err) => {
|
||||
logger.error({ msg: 'Failed to remove push token', err });
|
||||
});
|
||||
}
|
||||
|
||||
private shouldUseGateway(): boolean {
|
||||
if (License.hasOfflineLicense()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms'));
|
||||
}
|
||||
|
||||
private async sendNotificationNative(
|
||||
app: IPushToken,
|
||||
notification: PendingPushNotification,
|
||||
countApn: string[],
|
||||
countGcm: string[],
|
||||
): Promise<void> {
|
||||
logger.debug({ msg: 'send to token', token: app.token });
|
||||
|
||||
if ('apn' in app.token && app.token.apn) {
|
||||
const userToken = notification.useVoipToken ? app.voipToken : app.token.apn;
|
||||
const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName;
|
||||
|
||||
// Send to APN
|
||||
if (this.options.apn && userToken) {
|
||||
countApn.push(app._id);
|
||||
sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken });
|
||||
}
|
||||
} else if ('gcm' in app.token && app.token.gcm) {
|
||||
countGcm.push(app._id);
|
||||
|
||||
// override this.options.gcm.apiKey with the oauth2 token
|
||||
const { projectId, token } = await this.getNativeNotificationAuthorizationCredentials();
|
||||
const sendGCMOptions = {
|
||||
...this.options,
|
||||
gcm: {
|
||||
...this.options.gcm,
|
||||
apiKey: token,
|
||||
projectNumber: projectId,
|
||||
},
|
||||
};
|
||||
|
||||
sendFCM({
|
||||
userTokens: app.token.gcm,
|
||||
notification,
|
||||
_removeToken: this.removeToken,
|
||||
options: sendGCMOptions as RequiredField<PushOptions, 'gcm'>,
|
||||
});
|
||||
} else {
|
||||
throw new Error('send got a faulty query');
|
||||
}
|
||||
}
|
||||
|
||||
private async getNativeNotificationAuthorizationCredentials(): Promise<{ token: string; projectId: string }> {
|
||||
const credentialsString = settings.get<string>('Push_google_api_credentials');
|
||||
if (!credentialsString.trim()) {
|
||||
throw new Error('Push_google_api_credentials is not set');
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = JSON.parse(credentialsString);
|
||||
if (!isFCMCredentials(credentials)) {
|
||||
throw new Error('Push_google_api_credentials is not in the correct format');
|
||||
}
|
||||
|
||||
const client = new JWT({
|
||||
email: credentials.client_email,
|
||||
key: credentials.private_key,
|
||||
keyId: credentials.private_key_id,
|
||||
scopes: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
});
|
||||
|
||||
await client.authorize();
|
||||
|
||||
return {
|
||||
token: client.credentials.access_token as string,
|
||||
projectId: credentials.project_id,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ msg: 'Error getting FCM token', err: error });
|
||||
throw new Error('Error getting FCM token');
|
||||
}
|
||||
}
|
||||
|
||||
private async sendGatewayPush(
|
||||
gateway: string,
|
||||
service: 'apn' | 'gcm',
|
||||
token: string,
|
||||
notification: Optional<GatewayNotification, 'uniqueId'>,
|
||||
retryOptions: { tries: number; maxRetries: number } = { tries: 0, maxRetries: PUSH_GATEWAY_MAX_RETRIES },
|
||||
): Promise<void> {
|
||||
notification.uniqueId = this.options.uniqueId;
|
||||
|
||||
const options = {
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
method: 'POST',
|
||||
body: {
|
||||
token,
|
||||
options: notification,
|
||||
},
|
||||
...(token && this.options.getAuthorization && { headers: { Authorization: await this.options.getAuthorization() } }),
|
||||
} as ExtendedFetchOptions;
|
||||
|
||||
const result = await fetch(`${gateway}/push/${service}/send`, options);
|
||||
const response = await result.text();
|
||||
|
||||
if (result.status === 406) {
|
||||
logger.info({ msg: 'removing push token', token });
|
||||
this.removeToken(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 422) {
|
||||
logger.info({ msg: 'gateway rejected push notification. not retrying.', response });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 401) {
|
||||
logger.warn({ msg: 'authorization failed when sending push to gateway. not retrying.', response });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tries, maxRetries } = retryOptions;
|
||||
|
||||
logger.error({ msg: 'Error sending push to gateway', tries, err: response });
|
||||
|
||||
if (tries < maxRetries) {
|
||||
// [1, 2, 4, 8, 16] minutes (total 31)
|
||||
const ms = 60000 * Math.pow(2, tries);
|
||||
|
||||
logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms });
|
||||
|
||||
setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxRetries }), ms);
|
||||
}
|
||||
}
|
||||
|
||||
private getGatewayNotificationData(notification: PendingPushNotification): Omit<GatewayNotification, 'uniqueId'> {
|
||||
// Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority and useVoipToken
|
||||
// If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well.
|
||||
const { priority: _priority, useVoipToken: _useVoipToken, ...notifData } = notification;
|
||||
|
||||
return {
|
||||
...notifData,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendNotificationGateway(
|
||||
app: IPushToken,
|
||||
notification: PendingPushNotification,
|
||||
countApn: string[],
|
||||
countGcm: string[],
|
||||
): Promise<void> {
|
||||
if (!this.options.gateways) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gatewayNotification = this.getGatewayNotificationData(notification);
|
||||
const retryOptions = {
|
||||
tries: 0,
|
||||
maxRetries: notification.useVoipToken ? 0 : PUSH_GATEWAY_MAX_RETRIES,
|
||||
};
|
||||
|
||||
for (const gateway of this.options.gateways) {
|
||||
logger.debug({ msg: 'send to token', token: app.token });
|
||||
|
||||
if ('apn' in app.token && app.token.apn) {
|
||||
const token = notification.useVoipToken ? app.voipToken : app.token.apn;
|
||||
const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName;
|
||||
|
||||
if (token) {
|
||||
countApn.push(app._id);
|
||||
return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if ('gcm' in app.token && app.token.gcm) {
|
||||
countGcm.push(app._id);
|
||||
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNotification(
|
||||
notification: PendingPushNotification,
|
||||
options: { skipTokenId?: IPushToken['_id'] } = {},
|
||||
): Promise<{ apn: string[]; gcm: string[] }> {
|
||||
logger.debug({ msg: 'Sending notification', notification });
|
||||
|
||||
const countApn: string[] = [];
|
||||
const countGcm: string[] = [];
|
||||
|
||||
if (notification.from && notification.from !== String(notification.from)) {
|
||||
throw new Error('Push.send: option "from" not a string');
|
||||
}
|
||||
if (notification.title && notification.title !== String(notification.title)) {
|
||||
throw new Error('Push.send: option "title" not a string');
|
||||
}
|
||||
if (notification.text && notification.text !== String(notification.text)) {
|
||||
throw new Error('Push.send: option "text" not a string');
|
||||
}
|
||||
|
||||
logger.debug({
|
||||
msg: 'send message to userId',
|
||||
title: notification.title,
|
||||
userId: notification.userId,
|
||||
});
|
||||
|
||||
const appTokens = options.skipTokenId
|
||||
? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId)
|
||||
: PushToken.findAllTokensByUserId(notification.userId);
|
||||
|
||||
for await (const app of appTokens) {
|
||||
logger.debug({ msg: 'send to token', token: app.token });
|
||||
|
||||
if (this.shouldUseGateway()) {
|
||||
await this.sendNotificationGateway(app, notification, countApn, countGcm);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The workspace is configured to send through a push gateway, but the offline
|
||||
// license forbids contacting it — skip quietly instead of falling back to the
|
||||
// (unconfigured) native providers.
|
||||
if (this.options.gateways && License.hasOfflineLicense()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.sendNotificationNative(app, notification, countApn, countGcm);
|
||||
}
|
||||
|
||||
if (settings.get('Log_Level') === '2') {
|
||||
logger.debug({
|
||||
msg: 'Sent message to apps',
|
||||
title: notification.title,
|
||||
iosApps: countApn.length,
|
||||
androidApps: countGcm.length,
|
||||
});
|
||||
|
||||
// Add some verbosity about the send result, making sure the developer
|
||||
// understands what just happened.
|
||||
if (!countApn.length && !countGcm.length) {
|
||||
if ((await PushToken.estimatedDocumentCount()) === 0) {
|
||||
logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...');
|
||||
}
|
||||
} else if (!countApn.length) {
|
||||
if ((await PushToken.countApnTokens()) === 0) {
|
||||
logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...');
|
||||
}
|
||||
} else if (!countGcm.length) {
|
||||
if ((await PushToken.countGcmTokens()) === 0) {
|
||||
logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apn: countApn,
|
||||
gcm: countGcm,
|
||||
};
|
||||
}
|
||||
|
||||
// This is a general function to validate that the data added to notifications
|
||||
// is in the correct format. If not this function will throw errors
|
||||
private _validateDocument(notification: PendingPushNotification): void {
|
||||
// Check the general notification
|
||||
check(notification, {
|
||||
from: Match.Optional(String),
|
||||
title: Match.Optional(String),
|
||||
text: Match.Optional(String),
|
||||
sent: Match.Optional(Boolean),
|
||||
sending: Match.Optional(Match.Integer),
|
||||
badge: Match.Optional(Match.Integer),
|
||||
sound: Match.Optional(String),
|
||||
notId: Match.Optional(Match.Integer),
|
||||
contentAvailable: Match.Optional(Match.Integer),
|
||||
apn: Match.Optional({
|
||||
category: Match.Optional(String),
|
||||
expirationSeconds: Match.Optional(Match.Integer),
|
||||
}),
|
||||
gcm: Match.Optional({
|
||||
image: Match.Optional(String),
|
||||
style: Match.Optional(String),
|
||||
}),
|
||||
userId: String,
|
||||
payload: Match.Optional(Object),
|
||||
createdAt: Date,
|
||||
createdBy: Match.OneOf(String, null),
|
||||
priority: Match.Optional(Match.Integer),
|
||||
useVoipToken: Match.Optional(Boolean),
|
||||
});
|
||||
|
||||
if (!notification.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
}
|
||||
|
||||
private hasApnOptions(options: IPushNotificationConfig): options is RequiredField<IPushNotificationConfig, 'apn'> {
|
||||
return Match.test(options.apn, Object);
|
||||
}
|
||||
|
||||
private hasGcmOptions(options: IPushNotificationConfig): options is RequiredField<IPushNotificationConfig, 'gcm'> {
|
||||
return Match.test(options.gcm, Object);
|
||||
}
|
||||
|
||||
public async send(options: IPushNotificationConfig) {
|
||||
const notification: PendingPushNotification = {
|
||||
createdAt: new Date(),
|
||||
// createdBy is no longer used, but the gateway still expects it
|
||||
createdBy: '<SERVER>',
|
||||
sent: false,
|
||||
sending: 0,
|
||||
...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }),
|
||||
...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }),
|
||||
|
||||
...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'),
|
||||
|
||||
...(this.hasApnOptions(options)
|
||||
? {
|
||||
apn: {
|
||||
...pick(options.apn, 'category', 'expirationSeconds'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(this.hasGcmOptions(options)
|
||||
? {
|
||||
gcm: {
|
||||
...pick(options.gcm, 'image', 'style'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Validate the notification
|
||||
this._validateDocument(notification);
|
||||
|
||||
try {
|
||||
await this.sendNotification(notification, { skipTokenId: options.skipTokenId });
|
||||
} catch (error: any) {
|
||||
logger.debug({
|
||||
msg: 'Could not send notification to user',
|
||||
userId: notification.userId,
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Push = new PushClass();
|
||||
182
apps/meteor/server/lib/notifications/queue/NotificationQueue.ts
Normal file
182
apps/meteor/server/lib/notifications/queue/NotificationQueue.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { INotification, INotificationItemPush, INotificationItemEmail, NotificationItem, IUser } from '@rocket.chat/core-typings';
|
||||
import { NotificationQueue, Users } from '@rocket.chat/models';
|
||||
import { tracerSpan } from '@rocket.chat/tracing';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { SystemLogger } from '../../logger/system';
|
||||
import { sendEmailFromData } from '../message/email';
|
||||
import { PushNotification } from '../push-config';
|
||||
|
||||
const {
|
||||
NOTIFICATIONS_WORKER_TIMEOUT = 2000,
|
||||
NOTIFICATIONS_BATCH_SIZE = 100,
|
||||
NOTIFICATIONS_SCHEDULE_DELAY_ONLINE = 120,
|
||||
NOTIFICATIONS_SCHEDULE_DELAY_AWAY = 0,
|
||||
NOTIFICATIONS_SCHEDULE_DELAY_OFFLINE = 0,
|
||||
} = process.env;
|
||||
|
||||
class NotificationClass {
|
||||
private running = false;
|
||||
|
||||
private cyclePause = Number(NOTIFICATIONS_WORKER_TIMEOUT);
|
||||
|
||||
private maxBatchSize = Number(NOTIFICATIONS_BATCH_SIZE);
|
||||
|
||||
private maxScheduleDelaySeconds: { [key: string]: number } = {
|
||||
online: Number(NOTIFICATIONS_SCHEDULE_DELAY_ONLINE),
|
||||
away: Number(NOTIFICATIONS_SCHEDULE_DELAY_AWAY),
|
||||
offline: Number(NOTIFICATIONS_SCHEDULE_DELAY_OFFLINE),
|
||||
};
|
||||
|
||||
initWorker(): void {
|
||||
this.running = true;
|
||||
this.executeWorkerLater();
|
||||
}
|
||||
|
||||
stopWorker(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
executeWorkerLater(): void {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const continueLater = await tracerSpan(
|
||||
'NotificationWorker',
|
||||
{
|
||||
attributes: {
|
||||
workerTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
() => this.worker(),
|
||||
);
|
||||
|
||||
if (continueLater) {
|
||||
this.executeWorkerLater();
|
||||
}
|
||||
} catch (err) {
|
||||
SystemLogger.error({ msg: 'Error sending notification', err });
|
||||
this.executeWorkerLater();
|
||||
}
|
||||
}, this.cyclePause);
|
||||
}
|
||||
|
||||
async worker(counter = 0): Promise<boolean> {
|
||||
const notification = await this.getNextNotification();
|
||||
|
||||
if (!notification) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Once we start notifying the user we anticipate all the schedules
|
||||
const flush = await NotificationQueue.clearScheduleByUserId(notification.uid);
|
||||
|
||||
// start worker again if queue flushed
|
||||
if (flush.modifiedCount) {
|
||||
await NotificationQueue.unsetSendingById(notification._id);
|
||||
return this.worker(counter);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const item of notification.items) {
|
||||
switch (item.type) {
|
||||
case 'push':
|
||||
await this.push(notification, item);
|
||||
break;
|
||||
case 'email':
|
||||
await this.email(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await NotificationQueue.removeById(notification._id);
|
||||
} catch (err) {
|
||||
SystemLogger.error({ err });
|
||||
await NotificationQueue.setErrorById(notification._id, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
if (counter >= this.maxBatchSize) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.worker(++counter);
|
||||
}
|
||||
|
||||
getNextNotification(): Promise<INotification | null> {
|
||||
const expired = new Date();
|
||||
expired.setMinutes(expired.getMinutes() - 5);
|
||||
|
||||
return NotificationQueue.findNextInQueueOrExpired(expired);
|
||||
}
|
||||
|
||||
async push({ uid, rid, mid }: INotification, item: INotificationItemPush): Promise<void> {
|
||||
await PushNotification.send({
|
||||
rid,
|
||||
uid,
|
||||
mid,
|
||||
...item.data,
|
||||
});
|
||||
}
|
||||
|
||||
async email(item: INotificationItemEmail): Promise<void> {
|
||||
return sendEmailFromData(item.data);
|
||||
}
|
||||
|
||||
async scheduleItem({
|
||||
uid,
|
||||
rid,
|
||||
mid,
|
||||
items,
|
||||
user,
|
||||
}: {
|
||||
uid: string;
|
||||
rid: string;
|
||||
mid: string;
|
||||
items: NotificationItem[];
|
||||
user?: Partial<IUser>;
|
||||
}): Promise<void> {
|
||||
const receiver =
|
||||
user ||
|
||||
(await Users.findOneById<Pick<IUser, 'statusConnection'>>(uid, {
|
||||
projection: {
|
||||
statusConnection: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { statusConnection = 'offline' } = receiver;
|
||||
|
||||
let schedule: Date | undefined;
|
||||
|
||||
const delay = this.maxScheduleDelaySeconds[statusConnection];
|
||||
|
||||
if (delay < 0) {
|
||||
return;
|
||||
}
|
||||
if (delay > 0) {
|
||||
schedule = new Date();
|
||||
schedule.setSeconds(schedule.getSeconds() + delay);
|
||||
}
|
||||
|
||||
await NotificationQueue.insertOne({
|
||||
uid,
|
||||
rid,
|
||||
mid,
|
||||
ts: new Date(),
|
||||
...(schedule && { schedule }),
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const Notification = new NotificationClass();
|
||||
|
||||
Meteor.startup(() => {
|
||||
Notification.initWorker();
|
||||
});
|
||||
Reference in New Issue
Block a user