UNPKG

smc-hub

Version:

CoCalc: Backend webserver component

214 lines (191 loc) 6.26 kB
/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ /* Handle all mentions that haven't yet been handled. */ const MIN_EMAIL_INTERVAL: string = process.env.COCALC_MENTIONS_MIN_EMAIL_INTERVAL || "8 hours"; // How long to wait between each round of handling notifications. let POLL_INTERVAL_S: number; if (process.env.COCALC_MENTIONS_POLL_INTERVAL_S != undefined) { POLL_INTERVAL_S = parseInt(process.env.COCALC_MENTIONS_POLL_INTERVAL_S); } else { POLL_INTERVAL_S = 15; } import { callback2 } from "smc-util/async-utils"; import { trunc } from "smc-util/misc"; import { callback, delay } from "awaiting"; import { project_has_network_access } from "../postgres/project-queries"; import { is_paying_customer } from "../postgres/account-queries"; import { send_email } from "../email"; const { HELP_EMAIL } = require("smc-util/theme"); // TODO: should be something like notifications@cocalc.com... const NOTIFICATIONS_EMAIL = HELP_EMAIL; // Determine entry in mentions table. interface Key { project_id: string; path: string; time: Date; target: string; } type Action = "email" | "ignore" | "no-network"; import { PostgreSQL } from "../postgres/types"; // Handle all notification, then wait for the given time, then again // handle all unhandled notifications. export async function handle_mentions_loop( db: PostgreSQL, poll_interval_s: number = POLL_INTERVAL_S ): Promise<void> { while (true) { try { await handle_all_mentions(db); } catch (err) { console.warn(`WARNING -- error handling mentions -- ${err}`); console.trace(); } await delay(poll_interval_s * 1000); } } export async function handle_all_mentions(db: any): Promise<void> { const result = await callback2(db._query, { select: ["time", "project_id", "path", "source", "target", "priority"], table: "mentions", where: "action is null", // no action taken yet. }); if (result == null || result.rows == null) { throw Error("invalid result"); // can't happen } for (const row of result.rows) { const project_id: string = row.project_id; const path: string = row.path; const time: Date = row.time; const source: string = row.source; const target: string = row.target; const priority: number = row.priority; const description: string = row.description; await handle_mention( db, { project_id, path, time, target }, source, priority, description ); } } async function determine_action( db: PostgreSQL, key: Key, source: string ): Promise<Action> { const { project_id, path, target } = key; if ( !(await is_paying_customer(db, source)) && !(await project_has_network_access(db, project_id)) ) { // Mentions are ignored when sending is NOT a paying customer *and* // the project does not have network access. // Otherwise, spammers could use @mentions to send emails. // Users can still see mentions inside CoCalc itself... return "no-network"; } const result = await callback2(db._query, { query: `SELECT COUNT(*) FROM mentions WHERE project_id=$1 AND path=$2 AND target=$3 AND action = 'email' AND time >= NOW() - INTERVAL '${MIN_EMAIL_INTERVAL}'`, params: [project_id, path, target], }); const count: number = parseInt(result.rows[0].count); if (count > 0) { return "ignore"; } return "email"; } export async function handle_mention( db: PostgreSQL, key: Key, source: string, _priority: number, // ignored for now. description?: string ): Promise<void> { // Check that source and target are both currently // collaborators on the project. const action: string = await determine_action(db, key, source); try { switch (action) { case "ignore": // Mark that we ignore this. await set_action(db, key, "ignore"); return; case "no-network": // Mark that we ignore this because no network. (basically a trial user) await set_action(db, key, "no-network"); return; case "email": await send_email_notification(db, key, source, description); // Mark that we sent email. await set_action(db, key, "email"); return; default: throw Error(`unknown action "${action}"`); } } catch (err) { await record_error(db, key, action, `${err}`); } } async function send_email_notification( db: PostgreSQL, key: Key, source: string, description: string = "" ): Promise<void> { // Gather relevant information to use to construct notification. const user_names = await callback2(db.account_ids_to_usernames, { account_ids: [source], }); const source_name = `${user_names[source].first_name} ${user_names[source].last_name}`; const project_title = await callback( db._get_project_column, "title", key.project_id ); const context = description.length > 0 ? `<br/><blockquote>${description}</blockquote>` : ""; const subject = `[${trunc(project_title, 40)}] ${key.path}`; const url = `https://cocalc.com/projects/${key.project_id}/files/${key.path}`; const body = `${source_name} mentioned you in <a href="${url}">a chat at ${key.path} in ${project_title}</a>.${context}`; let from: string; from = `${source_name} <${NOTIFICATIONS_EMAIL}>`; const to = await callback(db.get_user_column, "email_address", key.target); if (!to) { throw Error("no implemented way to notify target (no known email address)"); } const category = "notification"; const settings = await callback2(db.get_server_settings_cached, {}); // Send email notification. await callback2(send_email, { subject, body, from, to, category, settings }); } async function set_action( db: PostgreSQL, key: Key, action: string ): Promise<void> { await callback2(db._query, { query: "UPDATE mentions SET action=$1", params: [action], where: key, }); } export async function record_error( db, key: Key, action: string, error: string ): Promise<void> { await callback2(db._query, { query: "UPDATE mentions SET action=$1,error=$2", where: key, params: [action, error], }); }