smc-hub
Version:
CoCalc: Backend webserver component
214 lines (191 loc) • 6.26 kB
text/typescript
/*
* 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],
});
}