From d0f664f78efd6581ae3dd7f323e85d8d55049f62 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Sun, 2 Aug 2026 10:11:22 +0200 Subject: [PATCH] feat: email notification --- api/.env.example | 4 + api/src/App.ts | 2 + api/src/core/EventBus.ts | 3 +- api/src/core/all-events.ts | 2 + api/src/migrations/taskview/migrate.json | 11 + .../sql/1.62.0/0.create-invite-emails.sql | 13 + .../collaboration/CollaborationController.ts | 17 +- .../collaboration/CollaborationManager.ts | 22 +- .../collaboration/CollaborationRepository.ts | 15 +- .../collaboration/InviteEmailDispatcher.ts | 183 ++++++++++++++ .../__tests__/InviteEmailDispatcher.spec.ts | 233 ++++++++++++++++++ .../collaboration.server.types.ts | 28 +++ .../collaboration/mail/invite-en.ts | 50 ++++ .../collaboration/mail/invite-ru.ts | 50 ++++ api/src/tv-modules/goals/GoalsManager.ts | 2 +- api/src/tv-modules/messaging/utils.ts | 11 +- api/src/types/app.types.ts | 5 + api/src/utils/helpers.ts | 11 + .../1.environment-variables.md | 2 + .../taskview-db-schemas/src/index.ts | 1 + .../src/schemas/invite-emails.schema.ts | 14 ++ web/android/app/build.gradle | 4 +- web/ios/App/App.xcodeproj/project.pbxproj | 16 +- web/package.json | 2 +- 24 files changed, 657 insertions(+), 44 deletions(-) create mode 100644 api/src/migrations/taskview/sql/1.62.0/0.create-invite-emails.sql create mode 100644 api/src/tv-modules/collaboration/InviteEmailDispatcher.ts create mode 100644 api/src/tv-modules/collaboration/__tests__/InviteEmailDispatcher.spec.ts create mode 100644 api/src/tv-modules/collaboration/mail/invite-en.ts create mode 100644 api/src/tv-modules/collaboration/mail/invite-ru.ts create mode 100644 taskview-packages/taskview-db-schemas/src/schemas/invite-emails.schema.ts diff --git a/api/.env.example b/api/.env.example index 6507d50..475d4fd 100644 --- a/api/.env.example +++ b/api/.env.example @@ -27,6 +27,10 @@ SMTP_PASSWORD=your_smtp_password_here SMTP_ENCRYPTION=ssl SMTP_FROM_NAME=TaskView SMTP_FROM_EMAIL=your_email@example.com +# Email a person when they are invited to a project (requires SMTP) +INVITE_EMAIL_ENABLED=false +# Max invite emails one user may trigger per hour (default 30) +# INVITE_EMAIL_HOURLY_LIMIT=30 # Encryption (32-byte hex key for AES-256-GCM) # Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" diff --git a/api/src/App.ts b/api/src/App.ts index 15fdb75..58e0590 100644 --- a/api/src/App.ts +++ b/api/src/App.ts @@ -6,6 +6,7 @@ import errorHandler from './middlewares/error-handler'; import routes from './routes'; import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login'; import { LoginMethods } from './tv-modules/auth/LoginMethods'; +import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher'; import { PublicApiUrl } from './modules/public-url'; import cookieParser from 'cookie-parser'; import { registerAllEventHandlers, startAllWorkers } from './core/all-events'; @@ -17,6 +18,7 @@ export default class App { constructor(port: number) { LoginMethods.validateOnStartup(); PublicApiUrl.validateOnStartup(); + InviteEmailDispatcher.validateOnStartup(); this.app = express(); this.port = port; diff --git a/api/src/core/EventBus.ts b/api/src/core/EventBus.ts index ab0010f..6634183 100644 --- a/api/src/core/EventBus.ts +++ b/api/src/core/EventBus.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events'; import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas'; import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types'; +import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types'; import { $logger } from '../modules/logget'; export interface AppEvents { @@ -8,7 +9,7 @@ export interface AppEvents { 'task.updated': { task: TasksSchemaTypeForSelect; changes: Record; initiatorId: number }; 'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number }; 'task.deleted': { taskId: number; goalId: number; initiatorId: number }; - 'collaboration.userAdded': { goalId: number; email: string; initiatorId: number }; + 'collaboration.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale }; 'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number }; 'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number }; 'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number }; diff --git a/api/src/core/all-events.ts b/api/src/core/all-events.ts index 3d6c2a9..7618e40 100644 --- a/api/src/core/all-events.ts +++ b/api/src/core/all-events.ts @@ -7,6 +7,7 @@ import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTracking import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher'; import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher'; import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher'; +import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher'; const dispatchers: Dispatcher[] = [ new NotificationDispatcher(), @@ -16,6 +17,7 @@ const dispatchers: Dispatcher[] = [ new SprintsDispatcher(), new RecurrenceDispatcher(), new MessagingDispatcher(), + new InviteEmailDispatcher(), ]; export function registerAllEventHandlers() { diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 3ff5c95..e371cc3 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -726,5 +726,16 @@ "description": [ "Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'" ] + }, + "57": { + "version": "1.62.0", + "name": "Invite email rate limiting", + "releaseDate": "20260730", + "scripts": [ + "/1.62.0/0.create-invite-emails.sql" + ], + "description": [ + "Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap" + ] } } diff --git a/api/src/migrations/taskview/sql/1.62.0/0.create-invite-emails.sql b/api/src/migrations/taskview/sql/1.62.0/0.create-invite-emails.sql new file mode 100644 index 0000000..db7b45a --- /dev/null +++ b/api/src/migrations/taskview/sql/1.62.0/0.create-invite-emails.sql @@ -0,0 +1,13 @@ +-- Log of sent project-invite emails, used to rate-limit sending: +-- a 24h per-recipient cooldown and an hourly cap per initiator. +-- Rows older than 24 hours are pruned opportunistically before each insert. +CREATE TABLE IF NOT EXISTS collaboration.invite_emails ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE, + sent_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at); +CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at); diff --git a/api/src/tv-modules/collaboration/CollaborationController.ts b/api/src/tv-modules/collaboration/CollaborationController.ts index 5dd3887..382298e 100644 --- a/api/src/tv-modules/collaboration/CollaborationController.ts +++ b/api/src/tv-modules/collaboration/CollaborationController.ts @@ -82,19 +82,30 @@ export class CollaborationController { return res.status(400).send(output.summary); } - const user = await req.appUser.collaborationManager.addUserNew(output); + const result = await req.appUser.collaborationManager.addUserNew(output); - if (user) { + // created=false means the person was already in the goal — re-POSTing must not re-notify + if (result?.created) { eventBus.emit('collaboration.userAdded', { goalId: output.goalId, email: output.email.toLowerCase(), initiatorId: req.appUser.getUserData()!.id, + locale: this.resolveLocale(req), }); } - return res.tvJson(user ?? null); + return res.tvJson(result?.user ?? null); }; + // The invitee has no stored locale (often no account yet), so localize by the inviter's browser language + private resolveLocale(req: Request): 'en' | 'ru' { + const acceptLanguage = req.headers['accept-language']; + if (!acceptLanguage) return 'en'; + + const languages = acceptLanguage.split(',').map((lang) => lang.split(';')[0].trim().toLowerCase()); + return languages.some((lang) => lang === 'ru' || lang.startsWith('ru-')) ? 'ru' : 'en'; + } + deleteUserNew = async (req: Request, res: Response) => { const output = CollaborationArkTypeDeleteUser(req.body); diff --git a/api/src/tv-modules/collaboration/CollaborationManager.ts b/api/src/tv-modules/collaboration/CollaborationManager.ts index 7624b39..d612842 100644 --- a/api/src/tv-modules/collaboration/CollaborationManager.ts +++ b/api/src/tv-modules/collaboration/CollaborationManager.ts @@ -2,6 +2,7 @@ import type { AppUser } from '../../core/AppUser'; import { GoalPermissions } from '../../types/auth.types'; import { CollaborationRepository } from './CollaborationRepository'; import type { + CollaborationAddUserResult, CollaborationArgAddUser, CollaborationArgDeleteUser, CollaborationArgToggleUserRoles, @@ -120,7 +121,7 @@ export class CollaborationManager { return await this.repository.deleteUser(args); } - async addUserNew(args: CollaborationArgAddUser): Promise { + async addUserNew(args: CollaborationArgAddUser): Promise { const email = args.email.toLowerCase(); const goal = await this.user.goalsManager.goalsRepository.findGoalById(args.goalId); @@ -131,19 +132,22 @@ export class CollaborationManager { } } - const user = await this.repository.addUserForCollaborationNew({ + const result = await this.repository.addUserForCollaborationNew({ ...args, email, }); - if (!user) return null; + if (!result) return null; return { - ...user, - goalId: args.goalId, - goal_id: args.goalId, - invitation_date: user.invitationDate, - roles: [], - goalOwner: false, + user: { + ...result.user, + goalId: args.goalId, + goal_id: args.goalId, + invitation_date: result.user.invitationDate, + roles: [], + goalOwner: false, + }, + created: result.created, }; } diff --git a/api/src/tv-modules/collaboration/CollaborationRepository.ts b/api/src/tv-modules/collaboration/CollaborationRepository.ts index a9e7c0b..d73cc06 100644 --- a/api/src/tv-modules/collaboration/CollaborationRepository.ts +++ b/api/src/tv-modules/collaboration/CollaborationRepository.ts @@ -10,6 +10,7 @@ import { $logger } from '../../modules/logget'; import { logError } from '../../utils/api'; import { callWithCatch } from '../../utils/helpers'; import type { + CollaborationAddUserRepoResult, CollaborationArgAddUser, CollaborationArgDeleteUser, CollaborationArgToggleUserRoles, @@ -197,8 +198,8 @@ export class CollaborationRepository { async addUserForCollaborationNew( args: CollaborationArgAddUser - ): Promise { - const user = await callWithCatch(() => + ): Promise { + return await callWithCatch(() => this.db.dbDrizzle.transaction(async (tx) => { let userId: number; let user: CollaborationUsersSchemaTypeForSelect; @@ -217,18 +218,14 @@ export class CollaborationRepository { user = userTransaction; } - await tx.insert(CollaborationUsersToGoalsSchema).values({ + const linked = await tx.insert(CollaborationUsersToGoalsSchema).values({ userId: userId, goalId: args.goalId, - }).onConflictDoNothing(); + }).onConflictDoNothing().returning(); - return user; + return { user, created: linked.length > 0 }; }) ); - - if (!user) return null; - - return user; } async deleteUserNew(args: CollaborationArgDeleteUser) { diff --git a/api/src/tv-modules/collaboration/InviteEmailDispatcher.ts b/api/src/tv-modules/collaboration/InviteEmailDispatcher.ts new file mode 100644 index 0000000..bf0ba26 --- /dev/null +++ b/api/src/tv-modules/collaboration/InviteEmailDispatcher.ts @@ -0,0 +1,183 @@ +import { and, count, eq, gte, lt, sql } from 'drizzle-orm'; +import { GoalsSchema, InviteEmailsSchema, OrganizationsSchema, UsersSchema } from 'taskview-db-schemas'; +import type { Dispatcher } from '../../core/Dispatcher'; +import { Email } from '../../core/Email'; +import { eventBus, type AppEvents } from '../../core/EventBus'; +import { Database } from '../../modules/db'; +import { $logger } from '../../modules/logget'; +import { escapeHtml, parsePositiveInt } from '../../utils/helpers'; +import InviteEmailTemplateEn from './mail/invite-en'; +import InviteEmailTemplateRu from './mail/invite-ru'; +import type { InviteEmailRateLimitArgs, InviteEmailSendArgs } from './collaboration.server.types'; + +const DEFAULT_HOURLY_LIMIT = 30; + +export class InviteEmailDispatcher implements Dispatcher { + static enabled(): boolean { + return process.env.INVITE_EMAIL_ENABLED?.trim().toLowerCase() === 'true'; + } + + static hourlyLimit(): number { + return parsePositiveInt(process.env.INVITE_EMAIL_HOURLY_LIMIT) ?? DEFAULT_HOURLY_LIMIT; + } + + static validateOnStartup(): void { + const enabledRaw = process.env.INVITE_EMAIL_ENABLED; + if (enabledRaw !== undefined && enabledRaw.trim() !== '') { + const normalized = enabledRaw.trim().toLowerCase(); + if (normalized !== 'true' && normalized !== 'false') { + throw new Error(`INVITE_EMAIL_ENABLED has unrecognized value "${enabledRaw}". Allowed: true, false`); + } + } + + const limitRaw = process.env.INVITE_EMAIL_HOURLY_LIMIT; + if (limitRaw !== undefined && limitRaw.trim() !== '' && parsePositiveInt(limitRaw) === null) { + throw new Error( + `INVITE_EMAIL_HOURLY_LIMIT has unrecognized value "${limitRaw}". Expected a positive integer` + ); + } + } + + register(): void { + eventBus.on('collaboration.userAdded', (data) => this.onUserAdded(data)); + } + + async registerWorkers(): Promise {} + + private async onUserAdded(data: AppEvents['collaboration.userAdded']): Promise { + if (!InviteEmailDispatcher.enabled() || !process.env.SMTP_HOST) return; + + const db = Database.getInstance(); + + const [goal] = await db.dbDrizzle + .select({ name: GoalsSchema.name, organizationId: GoalsSchema.organizationId }) + .from(GoalsSchema) + .where(eq(GoalsSchema.id, data.goalId)) + .limit(1); + if (!goal) return; + + const [inviter] = await db.dbDrizzle + .select({ login: UsersSchema.login }) + .from(UsersSchema) + .where(eq(UsersSchema.id, data.initiatorId)) + .limit(1); + if (!inviter) return; + + const allowed = await this.passesRateLimit({ + initiatorId: data.initiatorId, + email: data.email, + goalId: data.goalId, + }); + if (!allowed) return; + + const link = await this.buildGoalLink(data.goalId, goal.organizationId); + if (!link) { + $logger.warn('APP_URL is not set — skipping invite email'); + return; + } + + await db.dbDrizzle.insert(InviteEmailsSchema).values({ + initiatorId: data.initiatorId, + email: data.email, + goalId: data.goalId, + }); + + const fallbackName = data.locale === 'ru' ? 'Пользователь TaskView' : 'A TaskView user'; + + await this.sendInviteEmail({ + email: data.email, + inviterName: this.truncate(inviter.login?.trim() || fallbackName), + goalName: this.truncate(goal.name || ''), + link, + locale: data.locale, + }); + } + + // Two rules: a 24h cooldown per (goal, recipient) — closes the delete/re-add resend loop — + // and an hourly cap per initiator against using the instance as a mail relay. + // Rows older than the cooldown window are pruned first, keeping the table tiny. + private async passesRateLimit(args: InviteEmailRateLimitArgs): Promise { + const db = Database.getInstance(); + + await db.dbDrizzle + .delete(InviteEmailsSchema) + .where(lt(InviteEmailsSchema.sentAt, sql`now() - interval '24 hours'`)); + + const [cooldown] = await db.dbDrizzle + .select({ id: InviteEmailsSchema.id }) + .from(InviteEmailsSchema) + .where(and(eq(InviteEmailsSchema.goalId, args.goalId), eq(InviteEmailsSchema.email, args.email))) + .limit(1); + if (cooldown) return false; + + const [hourly] = await db.dbDrizzle + .select({ count: count() }) + .from(InviteEmailsSchema) + .where( + and( + eq(InviteEmailsSchema.initiatorId, args.initiatorId), + gte(InviteEmailsSchema.sentAt, sql`now() - interval '1 hour'`) + ) + ); + if ((hourly?.count ?? 0) >= InviteEmailDispatcher.hourlyLimit()) { + $logger.warn( + { initiatorId: args.initiatorId, goalId: args.goalId }, + 'Invite email hourly limit reached — skipping send' + ); + return false; + } + + return true; + } + + private async sendInviteEmail(args: InviteEmailSendArgs): Promise { + const template = args.locale === 'ru' ? InviteEmailTemplateRu : InviteEmailTemplateEn; + const subject = + args.locale === 'ru' + ? `${args.inviterName} приглашает вас в проект «${args.goalName}» в TaskView` + : `${args.inviterName} invited you to "${args.goalName}" on TaskView`; + const text = + args.locale === 'ru' + ? `${args.inviterName} приглашает вас присоединиться к проекту «${args.goalName}» в TaskView.\n\nОткрыть проект: ${args.link}` + : `${args.inviterName} has invited you to join the project "${args.goalName}" on TaskView.\n\nOpen the project: ${args.link}`; + + // Single-pass replace with a function: no re-substitution of placeholders inside + // inserted values, and no special treatment of $-patterns in the replacement + const values: Record = { + inviter: args.inviterName, + project: args.goalName, + link: args.link, + }; + const html = template.replace(/\{(inviter|project|link)\}/g, (_, key: string) => escapeHtml(values[key])); + + await Email.send({ + text, + subject, + to: args.email, + from: process.env.SMTP_FROM_EMAIL as string, + attachment: [{ data: html, alternative: true }], + }); + } + + // Frontend project route is /:orgSlug/:projectId; goals without an organization fall back to the app root + private async buildGoalLink(goalId: number, organizationId: number | null): Promise { + const appUrl = (process.env.APP_URL ?? '').replace(/\/+$/, ''); + if (!appUrl) return null; + if (!organizationId) return appUrl; + + const db = Database.getInstance(); + const [org] = await db.dbDrizzle + .select({ slug: OrganizationsSchema.slug }) + .from(OrganizationsSchema) + .where(eq(OrganizationsSchema.id, organizationId)) + .limit(1); + if (!org?.slug) return appUrl; + + return `${appUrl}/${encodeURIComponent(org.slug)}/${goalId}`; + } + + private truncate(value: string): string { + const max = 80; + return value.length > max ? `${value.slice(0, max)}…` : value; + } +} diff --git a/api/src/tv-modules/collaboration/__tests__/InviteEmailDispatcher.spec.ts b/api/src/tv-modules/collaboration/__tests__/InviteEmailDispatcher.spec.ts new file mode 100644 index 0000000..9b5bbf3 --- /dev/null +++ b/api/src/tv-modules/collaboration/__tests__/InviteEmailDispatcher.spec.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Email } from '../../../core/Email'; +import type { AppEvents } from '../../../core/EventBus'; +import { Database } from '../../../modules/db'; +import { InviteEmailDispatcher } from '../InviteEmailDispatcher'; + +vi.mock('../../../core/Email', () => ({ + Email: { + send: vi.fn().mockResolvedValue(true), + }, +})); + +vi.mock('../../../modules/db', () => ({ + Database: { + getInstance: vi.fn(), + }, +})); + +// Each select() call consumes the next result; the returned query is both awaitable +// (count query) and .limit()-able (lookups), matching the Drizzle chains in the dispatcher +function mockDb(selectResults: unknown[][]) { + const queue = [...selectResults]; + const insertValues = vi.fn(async () => undefined); + const dbDrizzle = { + select: vi.fn(() => { + const rows = queue.shift() ?? []; + const query = { + limit: async () => rows, + then: (resolve: (rows: unknown[]) => void, reject: (err: unknown) => void) => + Promise.resolve(rows).then(resolve, reject), + }; + return { from: () => ({ where: () => query }) }; + }), + delete: vi.fn(() => ({ where: async () => undefined })), + insert: vi.fn(() => ({ values: insertValues })), + }; + vi.mocked(Database.getInstance).mockReturnValue({ dbDrizzle } as any); + return { dbDrizzle, insertValues }; +} + +const goalRow = { name: 'Marketing', organizationId: 3 }; +const inviterRow = { login: 'Alice' }; +const noCooldown: unknown[] = []; +const underLimit = [{ count: 0 }]; +const orgRow = [{ slug: 'acme' }]; + +const inviteEvent: AppEvents['collaboration.userAdded'] = { + goalId: 42, + email: 'invitee@example.com', + initiatorId: 7, + locale: 'en', +}; + +describe('InviteEmailDispatcher', () => { + const dispatcher = new InviteEmailDispatcher(); + const onUserAdded = (data: typeof inviteEvent) => (dispatcher as any).onUserAdded(data); + const sentHtml = () => (vi.mocked(Email.send).mock.calls[0][0] as any).attachment[0].data as string; + + beforeEach(() => { + process.env.INVITE_EMAIL_ENABLED = 'true'; + process.env.SMTP_HOST = 'smtp.test'; + process.env.SMTP_FROM_EMAIL = 'noreply@test'; + process.env.APP_URL = 'http://localhost:3000'; + }); + + afterEach(() => { + delete process.env.INVITE_EMAIL_ENABLED; + delete process.env.INVITE_EMAIL_HOURLY_LIMIT; + vi.clearAllMocks(); + }); + + it('does not send when the flag is off', async () => { + process.env.INVITE_EMAIL_ENABLED = 'false'; + mockDb([]); + + await onUserAdded(inviteEvent); + + expect(Email.send).not.toHaveBeenCalled(); + }); + + it('does not send when the flag is unset', async () => { + delete process.env.INVITE_EMAIL_ENABLED; + mockDb([]); + + await onUserAdded(inviteEvent); + + expect(Email.send).not.toHaveBeenCalled(); + }); + + it('sends a localized email with a project deep link and records the send', async () => { + const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, underLimit, orgRow]); + + await onUserAdded(inviteEvent); + + expect(Email.send).toHaveBeenCalledTimes(1); + const message = vi.mocked(Email.send).mock.calls[0][0] as any; + expect(message.to).toBe('invitee@example.com'); + expect(message.from).toBe('noreply@test'); + expect(message.subject).toBe('Alice invited you to "Marketing" on TaskView'); + expect(message.text).toContain('http://localhost:3000/acme/42'); + + const html = sentHtml(); + expect(html).toContain("You've been invited to a project"); + expect(html).toContain('Alice'); + expect(html).toContain('href="http://localhost:3000/acme/42"'); + + expect(insertValues).toHaveBeenCalledWith({ + initiatorId: 7, + email: 'invitee@example.com', + goalId: 42, + }); + }); + + it('uses the Russian template for the ru locale', async () => { + mockDb([[{ name: 'Маркетинг', organizationId: null }], [{ login: 'Алиса' }], noCooldown, underLimit]); + + await onUserAdded({ ...inviteEvent, locale: 'ru' }); + + const message = vi.mocked(Email.send).mock.calls[0][0] as any; + expect(message.subject).toBe('Алиса приглашает вас в проект «Маркетинг» в TaskView'); + expect(sentHtml()).toContain('Вас пригласили в проект'); + expect(sentHtml()).toContain('href="http://localhost:3000"'); + }); + + it('skips the send during the per-recipient cooldown', async () => { + const { insertValues } = mockDb([[goalRow], [inviterRow], [{ id: 1 }]]); + + await onUserAdded(inviteEvent); + + expect(Email.send).not.toHaveBeenCalled(); + expect(insertValues).not.toHaveBeenCalled(); + }); + + it('skips the send when the hourly limit is reached', async () => { + const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 30 }]]); + + await onUserAdded(inviteEvent); + + expect(Email.send).not.toHaveBeenCalled(); + expect(insertValues).not.toHaveBeenCalled(); + }); + + it('respects a custom INVITE_EMAIL_HOURLY_LIMIT', async () => { + process.env.INVITE_EMAIL_HOURLY_LIMIT = '2'; + mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 2 }]]); + + await onUserAdded(inviteEvent); + + expect(Email.send).not.toHaveBeenCalled(); + + mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 1 }], orgRow]); + + await onUserAdded(inviteEvent); + + expect(Email.send).toHaveBeenCalledTimes(1); + }); + + it('escapes user-controlled values in the html', async () => { + mockDb([ + [{ name: '', organizationId: null }], + [{ login: 'Bob & "Co"' }], + noCooldown, + underLimit, + ]); + + await onUserAdded(inviteEvent); + + const html = sentHtml(); + expect(html).not.toContain(' { + mockDb([ + [{ name: 'Project $` name', organizationId: null }], + [{ login: '{link}' }], + noCooldown, + underLimit, + ]); + + await onUserAdded(inviteEvent); + + const html = sentHtml(); + expect(html).toContain('Project $` name'); + expect(html).toContain('{link}'); + expect(html).toContain('href="http://localhost:3000"'); + }); + + it('truncates overlong user values', async () => { + mockDb([ + [{ name: 'p'.repeat(200), organizationId: null }], + [{ login: 'i'.repeat(200) }], + noCooldown, + underLimit, + ]); + + await onUserAdded(inviteEvent); + + const message = vi.mocked(Email.send).mock.calls[0][0] as any; + expect(message.subject).toContain(`"${'p'.repeat(80)}…"`); + expect(message.text).toContain(`${'i'.repeat(80)}… has invited`); + }); + + it('does not send when the goal no longer exists', async () => { + mockDb([[]]); + + await onUserAdded(inviteEvent); + + expect(Email.send).not.toHaveBeenCalled(); + }); + + it('validateOnStartup rejects unrecognized values', () => { + process.env.INVITE_EMAIL_ENABLED = 'ture'; + expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_ENABLED'); + + process.env.INVITE_EMAIL_ENABLED = 'false'; + expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow(); + + process.env.INVITE_EMAIL_HOURLY_LIMIT = 'abc'; + expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT'); + + process.env.INVITE_EMAIL_HOURLY_LIMIT = '0'; + expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT'); + + process.env.INVITE_EMAIL_HOURLY_LIMIT = '10'; + expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow(); + + delete process.env.INVITE_EMAIL_ENABLED; + delete process.env.INVITE_EMAIL_HOURLY_LIMIT; + expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow(); + }); +}); diff --git a/api/src/tv-modules/collaboration/collaboration.server.types.ts b/api/src/tv-modules/collaboration/collaboration.server.types.ts index 9c6decc..0d909d9 100644 --- a/api/src/tv-modules/collaboration/collaboration.server.types.ts +++ b/api/src/tv-modules/collaboration/collaboration.server.types.ts @@ -1,4 +1,5 @@ import { type } from 'arktype'; +import type { CollaborationUsersSchemaTypeForSelect } from 'taskview-db-schemas'; export const CollaborationArkTypeAddUser = type({ goalId: 'number', @@ -97,3 +98,30 @@ export const CollaborationArkTypeToggleRolePermission = type({ }); export type CollaborationArgToggleRolePermission = typeof CollaborationArkTypeToggleRolePermission.infer; + +// created=false means the person was already a collaborator of the goal — no invitation happened +export type CollaborationAddUserRepoResult = { + user: CollaborationUsersSchemaTypeForSelect; + created: boolean; +}; + +export type CollaborationAddUserResult = { + user: CollaborationUserWithRoles; + created: boolean; +}; + +export type InviteEmailLocale = 'en' | 'ru'; + +export type InviteEmailSendArgs = { + email: string; + inviterName: string; + goalName: string; + link: string; + locale: InviteEmailLocale; +}; + +export type InviteEmailRateLimitArgs = { + initiatorId: number; + email: string; + goalId: number; +}; diff --git a/api/src/tv-modules/collaboration/mail/invite-en.ts b/api/src/tv-modules/collaboration/mail/invite-en.ts new file mode 100644 index 0000000..7438aaf --- /dev/null +++ b/api/src/tv-modules/collaboration/mail/invite-en.ts @@ -0,0 +1,50 @@ +export default ` + + + + + + Project invitation + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
TaskView
+
+

You've been invited to a project

+
+

{inviter} has invited you to join the project
{project}

+
+ Open project +
+

If the button doesn't work, copy this link into your browser:
{link}

+
+

You received this email because someone invited you to a project on TaskView. If you weren't expecting it, you can safely ignore this email.

+
+

© TaskView

+
+ +` diff --git a/api/src/tv-modules/collaboration/mail/invite-ru.ts b/api/src/tv-modules/collaboration/mail/invite-ru.ts new file mode 100644 index 0000000..c7622fb --- /dev/null +++ b/api/src/tv-modules/collaboration/mail/invite-ru.ts @@ -0,0 +1,50 @@ +export default ` + + + + + + Приглашение в проект + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
TaskView
+
+

Вас пригласили в проект

+
+

{inviter} приглашает вас присоединиться к проекту
{project}

+
+ Открыть проект +
+

Если кнопка не работает, скопируйте эту ссылку в браузер:
{link}

+
+

Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.

+
+

© TaskView

+
+ +` diff --git a/api/src/tv-modules/goals/GoalsManager.ts b/api/src/tv-modules/goals/GoalsManager.ts index 149680b..eee044a 100644 --- a/api/src/tv-modules/goals/GoalsManager.ts +++ b/api/src/tv-modules/goals/GoalsManager.ts @@ -237,7 +237,7 @@ export default class GoalsManager { await this.user.collaborationManager.repository.toggleUserRolesNew({ goalId, - userId: collabUser.id, + userId: collabUser.user.id, roles: [role.id], }) } diff --git a/api/src/tv-modules/messaging/utils.ts b/api/src/tv-modules/messaging/utils.ts index 2aef739..46cedbf 100644 --- a/api/src/tv-modules/messaging/utils.ts +++ b/api/src/tv-modules/messaging/utils.ts @@ -21,16 +21,7 @@ export function isSafeUrl(url: string): boolean { } } -// Escapes HTML text and attribute contexts (the quotes matter inside href="...") -// so a user-controlled value can't break out of a Telegram HTML message. -export function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +export { escapeHtml } from '../../utils/helpers'; // Slack mrkdwn requires escaping these three in text (incl. link labels). export function escapeSlackText(text: string): string { diff --git a/api/src/types/app.types.ts b/api/src/types/app.types.ts index 7d2fd55..1cad701 100644 --- a/api/src/types/app.types.ts +++ b/api/src/types/app.types.ts @@ -23,6 +23,11 @@ export const AppEnvSchema = z.object({ SMTP_FROM_EMAIL: z.string().optional(), APP_URL: z.string(), + // Send an email to a person when they are invited to a project (requires SMTP); default off + INVITE_EMAIL_ENABLED: z.string().optional(), + // Max invite emails one user may trigger per hour (default 30) + INVITE_EMAIL_HOURLY_LIMIT: z.string().optional(), + // How account password changes are confirmed: code sent by email (default) or current password PASSWORD_CHANGE_CONFIRMATION: z.enum(['email', 'password']).optional(), diff --git a/api/src/utils/helpers.ts b/api/src/utils/helpers.ts index 7e7052f..728ff43 100644 --- a/api/src/utils/helpers.ts +++ b/api/src/utils/helpers.ts @@ -2,6 +2,17 @@ import { randomInt } from 'crypto'; import { UAParser } from 'ua-parser-js'; import { $logger } from '../modules/logget'; +// Escapes HTML text and attribute contexts (the quotes matter inside href="...") +// so a user-controlled value can't break out of the surrounding markup. +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export function isEmail(email: string): boolean { const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index 093c29b..9d53d8e 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -88,6 +88,8 @@ Required for password recovery, email confirmation, and invitation notifications | `SMTP_ENCRYPTION` | No | `ssl` | `ssl` or `tls` | | `SMTP_FROM_NAME` | No | `TaskView` | Sender name in emails | | `SMTP_FROM_EMAIL` | No | - | Sender email address | +| `INVITE_EMAIL_ENABLED` | No | `false` | Set to `true` to email a person when they are invited to a project. The email is localized (English/Russian) by the inviter's browser language and links to the project. Requires SMTP; the value must be `true` or `false` — anything else stops the server at startup. | +| `INVITE_EMAIL_HOURLY_LIMIT` | No | `30` | Maximum invite emails one user may trigger per hour. On top of this cap, the same address is never emailed about the same project more than once per 24 hours. Must be a positive integer. | ## Encryption diff --git a/taskview-packages/taskview-db-schemas/src/index.ts b/taskview-packages/taskview-db-schemas/src/index.ts index c1b70b0..01d4a42 100644 --- a/taskview-packages/taskview-db-schemas/src/index.ts +++ b/taskview-packages/taskview-db-schemas/src/index.ts @@ -4,6 +4,7 @@ export * from './schemas/tasks-to-tags.schema'; export * from './schemas/tags.schema'; export * from './schemas/users.schema'; export * from './schemas/collaboration-users.schema'; +export * from './schemas/invite-emails.schema'; export * from './schemas/tasks-assignee.schema'; export * from './schemas/goals.schema'; export * from './schemas/goals-list.schema'; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/invite-emails.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/invite-emails.schema.ts new file mode 100644 index 0000000..cf8e881 --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/invite-emails.schema.ts @@ -0,0 +1,14 @@ +import { integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core"; +import { GoalsSchema } from "./goals.schema"; +import { UsersSchema } from "./users.schema"; + +export const InviteEmailsSchema = pgSchema('collaboration').table('invite_emails', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + initiatorId: integer('initiator_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }), + email: varchar({ length: 255 }).notNull(), + goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }), + sentAt: timestamp('sent_at').notNull().defaultNow(), +}); + +export type InviteEmailsSchemaTypeForSelect = typeof InviteEmailsSchema.$inferSelect; +export type InviteEmailsSchemaTypeForInsert = typeof InviteEmailsSchema.$inferInsert; diff --git a/web/android/app/build.gradle b/web/android/app/build.gradle index 5949f8e..3b80104 100644 --- a/web/android/app/build.gradle +++ b/web/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.handscreamgnl.taskview.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 14902 - versionName "1.49.2" + versionCode 15010 + versionName "1.50.10" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/web/ios/App/App.xcodeproj/project.pbxproj b/web/ios/App/App.xcodeproj/project.pbxproj index 5d1fdfe..55c8b99 100644 --- a/web/ios/App/App.xcodeproj/project.pbxproj +++ b/web/ios/App/App.xcodeproj/project.pbxproj @@ -436,7 +436,7 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.49.2; + CURRENT_PROJECT_VERSION = 1.50.10; DEVELOPMENT_TEAM = H2W2SG48JT; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -444,7 +444,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.49.2; + MARKETING_VERSION = 1.50.10; OTHER_LDFLAGS = ( "$(inherited)", "-weak_framework", @@ -466,7 +466,7 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.49.2; + CURRENT_PROJECT_VERSION = 1.50.10; DEVELOPMENT_TEAM = H2W2SG48JT; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -474,7 +474,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.49.2; + MARKETING_VERSION = 1.50.10; OTHER_LDFLAGS = ( "$(inherited)", "-weak_framework", @@ -495,7 +495,7 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.49.2; + CURRENT_PROJECT_VERSION = 1.50.10; DEVELOPMENT_TEAM = H2W2SG48JT; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TaskViewWidget/Info.plist; @@ -506,7 +506,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.49.2; + MARKETING_VERSION = 1.50.10; PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -524,7 +524,7 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.49.2; + CURRENT_PROJECT_VERSION = 1.50.10; DEVELOPMENT_TEAM = H2W2SG48JT; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TaskViewWidget/Info.plist; @@ -535,7 +535,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.49.2; + MARKETING_VERSION = 1.50.10; PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/web/package.json b/web/package.json index 79ac224..7fbdbe5 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "web-nuxt-ui", "private": true, "type": "module", - "version": "1.50.4", + "version": "1.50.10", "scripts": { "dev": "vite", "build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",