Merge pull request #96 from Gimanh/feat/email-notification

feat: email notification
This commit is contained in:
Nikolai Giman
2026-08-02 19:10:15 +02:00
committed by GitHub
24 changed files with 657 additions and 44 deletions
+4
View File
@@ -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'))"
+2
View File
@@ -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;
+2 -1
View File
@@ -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<string, unknown>; 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 };
+2
View File
@@ -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() {
+11
View File
@@ -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"
]
}
}
@@ -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);
@@ -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);
@@ -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<CollaborationUserWithRoles | null> {
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationAddUserResult | null> {
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,
};
}
@@ -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<CollaborationUsersSchemaTypeForSelect | null> {
const user = await callWithCatch(() =>
): Promise<CollaborationAddUserRepoResult | null> {
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) {
@@ -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<void> {}
private async onUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
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<boolean> {
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<void> {
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<string, string> = {
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<string | null> {
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;
}
}
@@ -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: '<img src=x onerror=alert(1)>', organizationId: null }],
[{ login: 'Bob & "Co"' }],
noCooldown,
underLimit,
]);
await onUserAdded(inviteEvent);
const html = sentHtml();
expect(html).not.toContain('<img src=x');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).toContain('Bob &amp; &quot;Co&quot;');
});
it('is immune to $-patterns and placeholder strings in user values', async () => {
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();
});
});
@@ -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;
};
@@ -0,0 +1,50 @@
export default `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="format-detection" content="telephone=no" />
<meta name="color-scheme" content="only" />
<title>Project invitation</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
<tr>
<td align="center" style="padding: 40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
<tr>
<td style="padding: 40px 32px 24px; text-align: center;">
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
</td>
</tr>
<tr>
<td style="padding: 0 32px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited to a project</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 28px; text-align: center;">
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> has invited you to join the project<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
</td>
</tr>
<tr>
<td align="center" style="padding: 0 32px 28px;">
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Open project</a>
</td>
</tr>
<tr>
<td style="padding: 0 32px 32px; text-align: center;">
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">If the button doesn't work, copy this link into your browser:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
</td>
</tr>
<tr>
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">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.</p>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
</td>
</tr>
</table>
</body>
</html>`
@@ -0,0 +1,50 @@
export default `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="format-detection" content="telephone=no" />
<meta name="color-scheme" content="only" />
<title>Приглашение в проект</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
<tr>
<td align="center" style="padding: 40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
<tr>
<td style="padding: 40px 32px 24px; text-align: center;">
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
</td>
</tr>
<tr>
<td style="padding: 0 32px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Вас пригласили в проект</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 28px; text-align: center;">
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> приглашает вас присоединиться к проекту<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
</td>
</tr>
<tr>
<td align="center" style="padding: 0 32px 28px;">
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Открыть проект</a>
</td>
</tr>
<tr>
<td style="padding: 0 32px 32px; text-align: center;">
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
</td>
</tr>
<tr>
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.</p>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
</td>
</tr>
</table>
</body>
</html>`
+1 -1
View File
@@ -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],
})
}
+1 -10
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export { escapeHtml } from '../../utils/helpers';
// Slack mrkdwn requires escaping these three in text (incl. link labels).
export function escapeSlackText(text: string): string {
+5
View File
@@ -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(),
+11
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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,}))$/;
@@ -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
@@ -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';
@@ -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;
+2 -2
View File
@@ -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.
+8 -8
View File
@@ -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;
+1 -1
View File
@@ -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",