From 1a7bc7efad0dcdf32d384fdc8b91ba164a9ebb10 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 12:11:36 -0300 Subject: [PATCH 01/16] chore: adds new validations to push notifications --- apps/meteor/app/push/server/push.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 95543aaa43e90..423ec199ad743 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -481,6 +481,24 @@ class PushClass { // Validate the notification this._validateDocument(notification); + // Truncate notification.text to 150 characters to keep the payload size small. + if (notification.text && notification.text.length > 150) { + notification.text = notification.text.slice(0, 150); + } + + // Check the size of the notification payload to guarantee it does not exceed the 4096 bytes limit (4KB) + const notificationByteSize = Buffer.byteLength(JSON.stringify(notification), 'utf8'); + if (notificationByteSize > 4096) { + logger.warn({ + size: notificationByteSize, + userId: notification.userId, + title: notification.title, + msg: 'Push notification payload size exceeds 4KB limit. Notification will not be sent.', + }); + + return; // Do not send the notification + } + try { await this.sendNotification(notification); } catch (error: any) { From 07e854004b9acbae7f8d4406d2664a11a40e0ced Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 12:19:51 -0300 Subject: [PATCH 02/16] chore: removes unnecessary comments --- apps/meteor/app/push/server/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 423ec199ad743..855ef680a7398 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -496,7 +496,7 @@ class PushClass { msg: 'Push notification payload size exceeds 4KB limit. Notification will not be sent.', }); - return; // Do not send the notification + return; } try { From dfc26c40f47aab3adaa6308d7fd3ac2dd529daae Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 13:29:16 -0300 Subject: [PATCH 03/16] docs: adds .changeset --- .changeset/rotten-pugs-trade.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rotten-pugs-trade.md diff --git a/.changeset/rotten-pugs-trade.md b/.changeset/rotten-pugs-trade.md new file mode 100644 index 0000000000000..fc973adc5f07e --- /dev/null +++ b/.changeset/rotten-pugs-trade.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Adds new validations for push notifications; truncates messages larger than 150 characters, checks notification payload size before dispatching the notification. From c27908f59995926897b5c56cdf530d21bb5a2bc6 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 16:13:15 -0300 Subject: [PATCH 04/16] tests: adds unit testing for PushClass.send function --- apps/meteor/tests/unit/app/push/push.spec.ts | 105 +++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 apps/meteor/tests/unit/app/push/push.spec.ts diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts new file mode 100644 index 0000000000000..30b79f99a8da9 --- /dev/null +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -0,0 +1,105 @@ +import type { IPushNotificationConfig } from '@rocket.chat/core-typings/src/IPushNotificationConfig'; +import { pick } from '@rocket.chat/tools'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('Push Notifications [PushClass]', () => { + let Push: any; + let loggerStub: any; + let settingsStub: any; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + loggerStub = { debug: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), info: sinon.stub(), log: sinon.stub() }; + settingsStub = { get: sinon.stub().returns('') }; + clock = sinon.useFakeTimers(); + + ({ Push } = proxyquire('../../../../../app/push/server/push', { + './logger': { logger: loggerStub }, + '../../settings/server': { settings: settingsStub }, + '@rocket.chat/tools': { pick }, + 'meteor/check': { + check: sinon.stub(), + Match: { + Optional: () => sinon.stub(), + Integer: Number, + OneOf: () => sinon.stub(), + test: sinon.stub().returns(true), + }, + }, + })); + }); + + afterEach(() => { + clock.restore(); + sinon.restore(); + }); + + describe('send()', () => { + let sendNotificationStub: sinon.SinonStub; + beforeEach(() => { + sendNotificationStub = sinon.stub(Push, 'sendNotification').resolves({ apn: [], gcm: [] }); + }); + + it('should call sendNotification with required fields', async () => { + const options: IPushNotificationConfig = { from: 'test', title: 'title', text: 'body', userId: 'user1' }; + + await Push.send(options); + + expect(sendNotificationStub.calledOnce).to.be.true; + + const notification = sendNotificationStub.firstCall.args[0]; + expect(notification.from).to.equal('test'); + expect(notification.title).to.equal('title'); + expect(notification.text).to.equal('body'); + expect(notification.userId).to.equal('user1'); + }); + + it('should truncate text longer than 150 chars', async () => { + const longText = 'a'.repeat(200); + const options: IPushNotificationConfig = { from: 'test', title: 'title', text: longText, userId: 'user1' }; + + await Push.send(options); + + const notification = sendNotificationStub.firstCall.args[0]; + + expect(notification.text.length).to.equal(150); + }); + + it('should not call sendNotification if payload exceeds 4KB', async () => { + const bigPayload: IPushNotificationConfig = { + from: 'test', + title: 'title', + text: 'body', + userId: 'user1', + payload: { data: 'a'.repeat(5000) }, + }; + + await Push.send(bigPayload); + + expect(sendNotificationStub.called).to.be.false; + expect(loggerStub.warn.calledWithMatch(sinon.match.object)).to.be.true; + }); + + it('should throw if userId is missing', async () => { + const options = { from: 'test', title: 'title', text: 'body' }; + try { + await Push.send(options); + } catch (e) { + // expected + } + expect(sendNotificationStub.called).to.be.false; + }); + + it('should handle errors from sendNotification gracefully', async () => { + sendNotificationStub.rejects(new Error('fail')); + + const options: IPushNotificationConfig = { from: 'test', title: 'title', text: 'body', userId: 'user1' }; + + await Push.send(options); + + expect(loggerStub.debug.calledWithMatch(sinon.match.string, sinon.match.string)).to.be.true; + }); + }); +}); From 0b3b24df94d1194f481e9fc0b7e86c01c68f13a3 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 16:42:34 -0300 Subject: [PATCH 05/16] fix: proxyquire import path --- apps/meteor/tests/unit/app/push/push.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index 30b79f99a8da9..bef5af5ae4306 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -15,7 +15,7 @@ describe('Push Notifications [PushClass]', () => { settingsStub = { get: sinon.stub().returns('') }; clock = sinon.useFakeTimers(); - ({ Push } = proxyquire('../../../../../app/push/server/push', { + ({ Push } = proxyquire('../../../../app/push/server/push', { './logger': { logger: loggerStub }, '../../settings/server': { settings: settingsStub }, '@rocket.chat/tools': { pick }, From f525c11affad9c2bf06775a276eec60fd022477a Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 17:48:26 -0300 Subject: [PATCH 06/16] chore: adds minor improvement to push text size limit --- apps/meteor/app/push/server/push.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 855ef680a7398..eb6c014aa6319 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -15,6 +15,8 @@ import { settings } from '../../settings/server'; export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); +const MESSAGE_BODY_LIMIT = 150; + const ajv = new Ajv({ coerceTypes: true, }); @@ -482,21 +484,8 @@ class PushClass { this._validateDocument(notification); // Truncate notification.text to 150 characters to keep the payload size small. - if (notification.text && notification.text.length > 150) { - notification.text = notification.text.slice(0, 150); - } - - // Check the size of the notification payload to guarantee it does not exceed the 4096 bytes limit (4KB) - const notificationByteSize = Buffer.byteLength(JSON.stringify(notification), 'utf8'); - if (notificationByteSize > 4096) { - logger.warn({ - size: notificationByteSize, - userId: notification.userId, - title: notification.title, - msg: 'Push notification payload size exceeds 4KB limit. Notification will not be sent.', - }); - - return; + if (notification.text && notification.text.length > MESSAGE_BODY_LIMIT) { + notification.text = notification.text.slice(0, MESSAGE_BODY_LIMIT); } try { From f82b3bfcee5470a949c6486b667ed64db2321c75 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 17:48:51 -0300 Subject: [PATCH 07/16] fix: PushClass.send failing tests --- apps/meteor/tests/unit/app/push/push.spec.ts | 97 ++++++++++---------- 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index bef5af5ae4306..4396981d33cc3 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -4,33 +4,31 @@ import { expect } from 'chai'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; -describe('Push Notifications [PushClass]', () => { - let Push: any; - let loggerStub: any; - let settingsStub: any; - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - loggerStub = { debug: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), info: sinon.stub(), log: sinon.stub() }; - settingsStub = { get: sinon.stub().returns('') }; - clock = sinon.useFakeTimers(); - - ({ Push } = proxyquire('../../../../app/push/server/push', { - './logger': { logger: loggerStub }, - '../../settings/server': { settings: settingsStub }, - '@rocket.chat/tools': { pick }, - 'meteor/check': { - check: sinon.stub(), - Match: { - Optional: () => sinon.stub(), - Integer: Number, - OneOf: () => sinon.stub(), - test: sinon.stub().returns(true), - }, - }, - })); - }); +const loggerStub = { debug: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), info: sinon.stub(), log: sinon.stub() }; +const settingsStub = { get: sinon.stub().returns('') }; +const clock = sinon.useFakeTimers(); + +const { Push } = proxyquire.noCallThru().load('../../../../app/push/server/push', { + './logger': { logger: loggerStub }, + '../../settings/server': { settings: settingsStub }, + '@rocket.chat/tools': { pick }, + 'meteor/check': { + check: sinon.stub(), + Match: { + Optional: () => sinon.stub(), + Integer: Number, + OneOf: () => sinon.stub(), + test: sinon.stub().returns(true), + }, + }, + 'meteor/meteor': { + Meteor: { + absoluteUrl: sinon.stub().returns('http://localhost'), + }, + }, +}); +describe('Push Notifications [PushClass]', () => { afterEach(() => { clock.restore(); sinon.restore(); @@ -43,7 +41,14 @@ describe('Push Notifications [PushClass]', () => { }); it('should call sendNotification with required fields', async () => { - const options: IPushNotificationConfig = { from: 'test', title: 'title', text: 'body', userId: 'user1' }; + const options: IPushNotificationConfig = { + from: 'test', + title: 'title', + text: 'body', + userId: 'user1', + apn: { category: 'MESSAGE' }, + gcm: { style: 'inbox', image: 'url' }, + }; await Push.send(options); @@ -57,8 +62,15 @@ describe('Push Notifications [PushClass]', () => { }); it('should truncate text longer than 150 chars', async () => { - const longText = 'a'.repeat(200); - const options: IPushNotificationConfig = { from: 'test', title: 'title', text: longText, userId: 'user1' }; + const longText = 'a'.repeat(4000); + const options: IPushNotificationConfig = { + from: 'test', + title: 'title', + text: longText, + userId: 'user1', + apn: { category: 'MESSAGE' }, + gcm: { style: 'inbox', image: 'url' }, + }; await Push.send(options); @@ -67,39 +79,22 @@ describe('Push Notifications [PushClass]', () => { expect(notification.text.length).to.equal(150); }); - it('should not call sendNotification if payload exceeds 4KB', async () => { - const bigPayload: IPushNotificationConfig = { + it('should throw if userId is missing', async () => { + const options = { from: 'test', title: 'title', text: 'body', - userId: 'user1', - payload: { data: 'a'.repeat(5000) }, + apn: { category: 'MESSAGE' }, + gcm: { style: 'inbox', image: 'url' }, }; - await Push.send(bigPayload); - - expect(sendNotificationStub.called).to.be.false; - expect(loggerStub.warn.calledWithMatch(sinon.match.object)).to.be.true; - }); - - it('should throw if userId is missing', async () => { - const options = { from: 'test', title: 'title', text: 'body' }; try { await Push.send(options); } catch (e) { // expected } - expect(sendNotificationStub.called).to.be.false; - }); - - it('should handle errors from sendNotification gracefully', async () => { - sendNotificationStub.rejects(new Error('fail')); - - const options: IPushNotificationConfig = { from: 'test', title: 'title', text: 'body', userId: 'user1' }; - await Push.send(options); - - expect(loggerStub.debug.calledWithMatch(sinon.match.string, sinon.match.string)).to.be.true; + expect(sendNotificationStub.called).to.be.false; }); }); }); From 412a75f6a5323cbb79ece5e662ad33cb4ad8ba8f Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 17:53:57 -0300 Subject: [PATCH 08/16] tests: adds improvements to error assertion --- apps/meteor/tests/unit/app/push/push.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index 4396981d33cc3..5f37f5d16e317 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -28,7 +28,7 @@ const { Push } = proxyquire.noCallThru().load('../../../../app/push/server/push' }, }); -describe('Push Notifications [PushClass]', () => { +describe.only('Push Notifications [PushClass]', () => { afterEach(() => { clock.restore(); sinon.restore(); @@ -88,11 +88,7 @@ describe('Push Notifications [PushClass]', () => { gcm: { style: 'inbox', image: 'url' }, }; - try { - await Push.send(options); - } catch (e) { - // expected - } + await expect(Push.send(options)).to.be.rejectedWith('No userId found'); expect(sendNotificationStub.called).to.be.false; }); From 5a206b8e8cb72964b439c1d6b679f00ff21db261 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 17:56:24 -0300 Subject: [PATCH 09/16] chore: removes .only --- apps/meteor/tests/unit/app/push/push.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index 5f37f5d16e317..6667cc9f845eb 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -28,7 +28,7 @@ const { Push } = proxyquire.noCallThru().load('../../../../app/push/server/push' }, }); -describe.only('Push Notifications [PushClass]', () => { +describe('Push Notifications [PushClass]', () => { afterEach(() => { clock.restore(); sinon.restore(); From 857629d7afa2b82fd6055bfa20d5bd6e2e0f2595 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Wed, 17 Dec 2025 18:08:12 -0300 Subject: [PATCH 10/16] chore: adds minor improvement from code review --- apps/meteor/app/push/server/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index eb6c014aa6319..0508dc01a9953 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -485,7 +485,7 @@ class PushClass { // Truncate notification.text to 150 characters to keep the payload size small. if (notification.text && notification.text.length > MESSAGE_BODY_LIMIT) { - notification.text = notification.text.slice(0, MESSAGE_BODY_LIMIT); + notification.text = `${notification.text.slice(0, MESSAGE_BODY_LIMIT - 3)}...`; } try { From 999ae4e46534bf83b637d3189abd83db9ccbb93a Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Thu, 18 Dec 2025 11:53:09 -0300 Subject: [PATCH 11/16] chore: removes unused sinon.useFakeTimers --- apps/meteor/tests/unit/app/push/push.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index 6667cc9f845eb..75f50c2bd736c 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -6,7 +6,6 @@ import sinon from 'sinon'; const loggerStub = { debug: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), info: sinon.stub(), log: sinon.stub() }; const settingsStub = { get: sinon.stub().returns('') }; -const clock = sinon.useFakeTimers(); const { Push } = proxyquire.noCallThru().load('../../../../app/push/server/push', { './logger': { logger: loggerStub }, @@ -30,7 +29,6 @@ const { Push } = proxyquire.noCallThru().load('../../../../app/push/server/push' describe('Push Notifications [PushClass]', () => { afterEach(() => { - clock.restore(); sinon.restore(); }); From d1ece1e221f14656284afc9b5f347075feaec6ef Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Thu, 18 Dec 2025 16:38:02 -0300 Subject: [PATCH 12/16] chores: adds some more improvements to push logic and new helper function --- apps/meteor/app/push/server/push.ts | 14 +++++------ apps/meteor/tests/unit/app/push/push.spec.ts | 26 +++++++++++++++++--- packages/tools/src/index.ts | 1 + packages/tools/src/truncateString.ts | 9 +++++++ 4 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 packages/tools/src/truncateString.ts diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 0508dc01a9953..9f8c693357858 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -1,7 +1,7 @@ import type { IAppsTokens, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; import { AppsTokens } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import { pick } from '@rocket.chat/tools'; +import { pick, truncateString } from '@rocket.chat/tools'; import Ajv from 'ajv'; import { JWT } from 'google-auth-library'; import { Match, check } from 'meteor/check'; @@ -15,7 +15,8 @@ import { settings } from '../../settings/server'; export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); -const MESSAGE_BODY_LIMIT = 150; +const PUSH_TITLE_LIMIT = 50; +const PUSH_MESSAGE_BODY_LIMIT = 150; const ajv = new Ajv({ coerceTypes: true, @@ -461,8 +462,10 @@ class PushClass { createdBy: '', sent: false, sending: 0, + title: options.title.length > PUSH_TITLE_LIMIT ? truncateString(options.title, PUSH_TITLE_LIMIT) : options.title, + text: options.text.length > PUSH_MESSAGE_BODY_LIMIT ? truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) : options.text, - ...pick(options, 'from', 'title', 'text', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), ...(this.hasApnOptions(options) ? { @@ -483,11 +486,6 @@ class PushClass { // Validate the notification this._validateDocument(notification); - // Truncate notification.text to 150 characters to keep the payload size small. - if (notification.text && notification.text.length > MESSAGE_BODY_LIMIT) { - notification.text = `${notification.text.slice(0, MESSAGE_BODY_LIMIT - 3)}...`; - } - try { await this.sendNotification(notification); } catch (error: any) { diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index 75f50c2bd736c..30ffea205d412 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -1,5 +1,5 @@ import type { IPushNotificationConfig } from '@rocket.chat/core-typings/src/IPushNotificationConfig'; -import { pick } from '@rocket.chat/tools'; +import { pick, truncateString } from '@rocket.chat/tools'; import { expect } from 'chai'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; @@ -10,7 +10,7 @@ const settingsStub = { get: sinon.stub().returns('') }; const { Push } = proxyquire.noCallThru().load('../../../../app/push/server/push', { './logger': { logger: loggerStub }, '../../settings/server': { settings: settingsStub }, - '@rocket.chat/tools': { pick }, + '@rocket.chat/tools': { pick, truncateString }, 'meteor/check': { check: sinon.stub(), Match: { @@ -59,8 +59,8 @@ describe('Push Notifications [PushClass]', () => { expect(notification.userId).to.equal('user1'); }); - it('should truncate text longer than 150 chars', async () => { - const longText = 'a'.repeat(4000); + it('should truncate text if longer than 150 chars', async () => { + const longText = 'a'.repeat(200); const options: IPushNotificationConfig = { from: 'test', title: 'title', @@ -77,6 +77,24 @@ describe('Push Notifications [PushClass]', () => { expect(notification.text.length).to.equal(150); }); + it('should truncate title if longer than 50 chars', async () => { + const longTitle = 'a'.repeat(100); + const options: IPushNotificationConfig = { + from: 'test', + title: longTitle, + text: 'bpdu', + userId: 'user1', + apn: { category: 'MESSAGE' }, + gcm: { style: 'inbox', image: 'url' }, + }; + + await Push.send(options); + + const notification = sendNotificationStub.firstCall.args[0]; + + expect(notification.title.length).to.equal(50); + }); + it('should throw if userId is missing', async () => { const options = { from: 'test', diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 8734e9f4d8c1e..4549ff513a238 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -13,3 +13,4 @@ export * from './removeEmpty'; export * from './isObject'; export * from './isRecord'; export * from './validateEmail'; +export * from './truncateString'; diff --git a/packages/tools/src/truncateString.ts b/packages/tools/src/truncateString.ts new file mode 100644 index 0000000000000..90ab54fd100bb --- /dev/null +++ b/packages/tools/src/truncateString.ts @@ -0,0 +1,9 @@ +export function truncateString(str: string, maxLength: number, shouldAddEllipses = true): string { + const ellipsis = '...'; + + if (shouldAddEllipses && str.length > maxLength) { + return `${str.slice(0, maxLength - 3)}${ellipsis}`; + } + + return str.slice(0, maxLength); +} From 776e2f40b42cf8cc12f90fa74b7b83bd16eb3c4c Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Thu, 18 Dec 2025 16:58:34 -0300 Subject: [PATCH 13/16] chore: adds improvements to truncateString --- packages/tools/src/truncateString.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/truncateString.ts b/packages/tools/src/truncateString.ts index 90ab54fd100bb..6de03c7eaa9e5 100644 --- a/packages/tools/src/truncateString.ts +++ b/packages/tools/src/truncateString.ts @@ -1,8 +1,22 @@ +/** + * Truncates a string to a specified maximum length, optionally adding ellipses. + * @param str + * @param maxLength + * @param shouldAddEllipses + * @return {string} + */ export function truncateString(str: string, maxLength: number, shouldAddEllipses = true): string { const ellipsis = '...'; + if (str.length <= maxLength) { + return str; + } if (shouldAddEllipses && str.length > maxLength) { - return `${str.slice(0, maxLength - 3)}${ellipsis}`; + if (maxLength <= ellipsis.length) { + return str.slice(0, maxLength); + } + + return `${str.slice(0, maxLength - ellipsis.length)}${ellipsis}`; } return str.slice(0, maxLength); From f0aba0330b64c7f0c31dff6f5bd444f30b08c879 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Thu, 18 Dec 2025 17:03:05 -0300 Subject: [PATCH 14/16] chore: adds improvements from code review --- apps/meteor/app/push/server/push.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 9f8c693357858..85fba7fb80ca5 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -462,8 +462,8 @@ class PushClass { createdBy: '', sent: false, sending: 0, - title: options.title.length > PUSH_TITLE_LIMIT ? truncateString(options.title, PUSH_TITLE_LIMIT) : options.title, - text: options.text.length > PUSH_MESSAGE_BODY_LIMIT ? truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) : options.text, + title: truncateString(options.title, PUSH_TITLE_LIMIT), + text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT), ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), From fe1f84ff60ed26417318bf4b3ed71469d2e12562 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Thu, 18 Dec 2025 17:23:15 -0300 Subject: [PATCH 15/16] docs: updates changeset --- .changeset/rotten-pugs-trade.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/rotten-pugs-trade.md b/.changeset/rotten-pugs-trade.md index fc973adc5f07e..0bfcdda0f5633 100644 --- a/.changeset/rotten-pugs-trade.md +++ b/.changeset/rotten-pugs-trade.md @@ -1,5 +1,6 @@ --- "@rocket.chat/meteor": patch +"@rocket.chat/tools": patch --- -Adds new validations for push notifications; truncates messages larger than 150 characters, checks notification payload size before dispatching the notification. +Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 150, and 50 characters respectively. From 947a0ddc76a988d7f65bf2bf1d3d4a8217e860f3 Mon Sep 17 00:00:00 2001 From: Lucas Pelegrino Date: Fri, 19 Dec 2025 10:13:22 -0300 Subject: [PATCH 16/16] chore: increases body message and title limits --- .changeset/rotten-pugs-trade.md | 2 +- apps/meteor/app/push/server/push.ts | 4 ++-- apps/meteor/tests/unit/app/push/push.spec.ts | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/rotten-pugs-trade.md b/.changeset/rotten-pugs-trade.md index 0bfcdda0f5633..37515336702d6 100644 --- a/.changeset/rotten-pugs-trade.md +++ b/.changeset/rotten-pugs-trade.md @@ -3,4 +3,4 @@ "@rocket.chat/tools": patch --- -Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 150, and 50 characters respectively. +Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 240, and 65 characters respectively. diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 85fba7fb80ca5..8382214a6b35a 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -15,8 +15,8 @@ import { settings } from '../../settings/server'; export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); -const PUSH_TITLE_LIMIT = 50; -const PUSH_MESSAGE_BODY_LIMIT = 150; +const PUSH_TITLE_LIMIT = 65; +const PUSH_MESSAGE_BODY_LIMIT = 240; const ajv = new Ajv({ coerceTypes: true, diff --git a/apps/meteor/tests/unit/app/push/push.spec.ts b/apps/meteor/tests/unit/app/push/push.spec.ts index 30ffea205d412..cfabd30d0291d 100644 --- a/apps/meteor/tests/unit/app/push/push.spec.ts +++ b/apps/meteor/tests/unit/app/push/push.spec.ts @@ -59,8 +59,8 @@ describe('Push Notifications [PushClass]', () => { expect(notification.userId).to.equal('user1'); }); - it('should truncate text if longer than 150 chars', async () => { - const longText = 'a'.repeat(200); + it('should truncate text if longer than 240 chars', async () => { + const longText = 'a'.repeat(300); const options: IPushNotificationConfig = { from: 'test', title: 'title', @@ -74,10 +74,10 @@ describe('Push Notifications [PushClass]', () => { const notification = sendNotificationStub.firstCall.args[0]; - expect(notification.text.length).to.equal(150); + expect(notification.text.length).to.equal(240); }); - it('should truncate title if longer than 50 chars', async () => { + it('should truncate title if longer than 65 chars', async () => { const longTitle = 'a'.repeat(100); const options: IPushNotificationConfig = { from: 'test', @@ -92,7 +92,7 @@ describe('Push Notifications [PushClass]', () => { const notification = sendNotificationStub.firstCall.args[0]; - expect(notification.title.length).to.equal(50); + expect(notification.title.length).to.equal(65); }); it('should throw if userId is missing', async () => {