Skip to content

Commit c62103f

Browse files
committed
rewrite sending emails to notify about mentions to (1) be cleaner code, (2) participate in the daily per-user email throttle limits, (3) NOT disallow sending mentions for users whose projects do not have network access or are not paying -- now everybody can mention, (4) fix a bug that meant no description, and (5) add plain text support. Also slightly more frequent mentions strategy by default.
1 parent 80e8db5 commit c62103f

File tree

13 files changed

+338
-299
lines changed

13 files changed

+338
-299
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Get email address of an account. If no such account or email not set, returns undefined...
2+
// Answers are cached for a while.
3+
4+
import getPool from "@cocalc/backend/database";
5+
6+
export default async function getEmailAddress(
7+
account_id: string
8+
): Promise<string | undefined> {
9+
const pool = getPool("long");
10+
const { rows } = await pool.query(
11+
"SELECT email_address FROM accounts WHERE account_id=$1",
12+
[account_id]
13+
);
14+
return rows[0]?.email_address;
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// If no such account or no name set, returns "Unknown User".
2+
// Answers are cached for a while.
3+
4+
import getPool from "@cocalc/backend/database";
5+
6+
export default async function getName(account_id: string): Promise<string> {
7+
const pool = getPool("long");
8+
const { rows } = await pool.query(
9+
"SELECT first_name, last_name FROM accounts WHERE account_id=$1",
10+
[account_id]
11+
);
12+
return `${rows[0]?.first_name ?? "Unknown"} ${rows[0]?.last_name ?? "User"}`;
13+
}

src/packages/backend/email/sendgrid.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/* We use the official V3 Sendgrid API:
22
33
https://www.npmjs.com/package/@sendgrid/mail
4+
5+
The cocalc ASM group numbers are at
6+
7+
https://app.sendgrid.com/suppressions/advanced_suppression_manager
48
*/
59

610
import getPool from "@cocalc/backend/database";
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
Handle all mentions that haven't yet been handled.
3+
*/
4+
5+
import getPool from "@cocalc/backend/database";
6+
import { delay } from "awaiting";
7+
import { getLogger } from "@cocalc/backend/logger";
8+
import type { Action, Key } from "./types";
9+
import notify from "./notify";
10+
11+
const logger = getLogger("mentions - handle");
12+
13+
// TODO: should be in the database server settings; also should be
14+
// user configurable, and this is just a default
15+
const minEmailInterval = "6 hours";
16+
const maxPerInterval = 2; // get up to 2 emails for a given chatroom every 6 hours.
17+
18+
// We check for new notifications this frequently.
19+
const polIntervalSeconds = 15;
20+
21+
// Handle all notification, then wait for the given time, then again
22+
// handle all unhandled notifications.
23+
export default async function init(): Promise<void> {
24+
while (true) {
25+
try {
26+
await handleAllMentions();
27+
} catch (err) {
28+
logger.warn(`WARNING -- error handling mentions -- ${err}`);
29+
}
30+
31+
await delay(polIntervalSeconds * 1000);
32+
}
33+
}
34+
35+
async function handleAllMentions(): Promise<void> {
36+
const pool = getPool();
37+
const { rows } = await pool.query(
38+
"SELECT time, project_id, path, source, target, description FROM mentions WHERE action IS null"
39+
);
40+
for (const row of rows) {
41+
const { time, project_id, path, source, target, description } = row;
42+
try {
43+
await handleMention(
44+
{ project_id, path, time, target },
45+
source,
46+
description ?? ""
47+
);
48+
} catch (err) {
49+
logger.warn(
50+
`WARNING -- error handling mention (will try later) -- ${err}`
51+
);
52+
}
53+
}
54+
}
55+
56+
async function handleMention(
57+
key: Key,
58+
source: string,
59+
description: string
60+
): Promise<void> {
61+
// Check that source and target are both currently
62+
// collaborators on the project.
63+
const action: Action = await determineAction(key);
64+
try {
65+
switch (action) {
66+
case "ignore": // already recently notified about this chatroom.
67+
await setAction(key, action);
68+
return;
69+
case "notify":
70+
let whatDid = await notify(key, source, description);
71+
// record what we did.
72+
await setAction(key, whatDid);
73+
return;
74+
default:
75+
throw Error(`BUG: unknown action "${action}"`);
76+
}
77+
} catch (err) {
78+
await setError(key, action, `${err}`);
79+
}
80+
}
81+
82+
async function determineAction(key: Key): Promise<Action> {
83+
const pool = getPool();
84+
const { rows } = await pool.query(
85+
`SELECT COUNT(*) FROM mentions WHERE project_id=$1 AND path=$2 AND target=$3 AND action = 'email' AND time >= NOW() - INTERVAL '${parseInt(
86+
minEmailInterval
87+
)}'`,
88+
[key.project_id, key.path, key.target]
89+
);
90+
const count: number = parseInt(rows[0]?.count ?? 0);
91+
if (count >= maxPerInterval) {
92+
return "ignore";
93+
}
94+
return "notify";
95+
}
96+
97+
async function setAction(key: Key, action: Action): Promise<void> {
98+
const pool = getPool();
99+
await pool.query(
100+
"UPDATE mentions SET action=$1 WHERE project_id=$2 AND path=$3 AND time=$4 AND target=$5",
101+
[action, key.project_id, key.path, key.time, key.target]
102+
);
103+
}
104+
105+
async function setError(
106+
key: Key,
107+
action: Action,
108+
error: string
109+
): Promise<void> {
110+
const pool = getPool();
111+
await pool.query(
112+
"UPDATE mentions SET action=$1, error=$2 WHERE project_id=$3 AND path=$4 AND time=$5 AND target=$6",
113+
[action, error, key.project_id, key.path, key.time, key.target]
114+
);
115+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Action, Key } from "./types";
2+
import getName from "@cocalc/backend/accounts/get-name";
3+
import getEmailAddress from "@cocalc/backend/accounts/get-email-address";
4+
import getProjectTitle from "@cocalc/backend/projects/get-title";
5+
import { trunc } from "@cocalc/util/misc";
6+
import siteURL from "@cocalc/backend/server-settings/site-url";
7+
import { getServerSettings } from "@cocalc/backend/server-settings";
8+
import sendEmail from "@cocalc/backend/email/send-email";
9+
10+
export default async function sendNotificationIfPossible(
11+
key: Key,
12+
source: string,
13+
description: string
14+
): Promise<Action> {
15+
const to = await getEmailAddress(key.target);
16+
if (!to) {
17+
// Email is the only notification method at present.
18+
// Nothing more to do -- target user has no known email address.
19+
// They will see notification when they sign in.
20+
return "nothing";
21+
}
22+
23+
const sourceName = trunc(await getName(source), 60);
24+
const projectTitle = await getProjectTitle(key.project_id);
25+
26+
const context =
27+
description.length > 0
28+
? `<br/><blockquote>${description}</blockquote>`
29+
: "";
30+
const subject = `[${trunc(projectTitle, 40)}] ${key.path}`;
31+
const url = `${await siteURL()}/projects/${key.project_id}/files/${key.path}`;
32+
const html = `
33+
${sourceName} mentioned you in
34+
<a href="${url}">a chat at ${key.path} in ${projectTitle}</a>.
35+
${context}
36+
`;
37+
38+
const text = `
39+
${sourceName} mentioned you in a chat at ${key.path} in ${projectTitle}.
40+
41+
${url}
42+
43+
${description ? "> " : ""}${description}
44+
`;
45+
46+
const { help_email } = await getServerSettings();
47+
const from = `${sourceName} <${help_email}>`;
48+
49+
await sendEmail(
50+
{
51+
from,
52+
to,
53+
subject,
54+
text,
55+
html,
56+
categories: ["notification"],
57+
asm_group: 148185, // see https://app.sendgrid.com/suppressions/advanced_suppression_manager
58+
},
59+
source
60+
);
61+
return "email";
62+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface Key {
2+
project_id: string;
3+
path: string;
4+
time: Date;
5+
target: string;
6+
}
7+
8+
9+
export type Action = "ignore" | "notify" | "email" | "nothing";
10+

src/packages/backend/package-lock.json

Lines changed: 23 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@types/pg": "^8.6.1",
3535
"async": "^1.5.2",
3636
"async-await-utils": "^3.0.1",
37+
"awaiting": "^3.0.0",
3738
"debug": "^4.3.2",
3839
"lru-cache": "^6.0.0",
3940
"nodemailer": "^6.6.5",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import getPool from "@cocalc/backend/database";
2+
3+
export default async function getTitle(project_id: string): Promise<string> {
4+
const pool = getPool("long");
5+
const { rows } = await pool.query(
6+
"SELECT title FROM projects WHERE project_id=$1",
7+
[project_id]
8+
);
9+
return rows[0]?.title ?? "Untitled Project";
10+
}

src/packages/hub/hub.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import base_path from "@cocalc/backend/base-path";
2323
import { migrate_account_token } from "./postgres/migrate-account-token";
2424
import { init_start_always_running_projects } from "./postgres/always-running";
2525
import { set_agent_endpoint } from "./health-checks";
26-
import { handle_mentions_loop } from "./mentions/handle";
26+
import initHandleMentions from "@cocalc/backend/mentions/handle";
2727
const MetricsRecorder = require("./metrics-recorder"); // import * as MetricsRecorder from "./metrics-recorder";
2828
import { start as startHubRegister } from "./hub_register";
2929
const initZendesk = require("./support").init_support; // import { init_support as initZendesk } from "./support";
@@ -152,7 +152,7 @@ async function startServer(): Promise<void> {
152152
// Mentions
153153
if (program.mentions) {
154154
winston.info("enabling handling of mentions...");
155-
handle_mentions_loop(database);
155+
initHandleMentions();
156156
}
157157

158158
// Project control
@@ -215,7 +215,7 @@ async function startServer(): Promise<void> {
215215
projectControl,
216216
proxyServer: !!program.proxyServer,
217217
nextServer: !!program.nextServer,
218-
cert: program.httpsCert,
218+
cert: program.httpsCert,
219219
key: program.httpsKey,
220220
});
221221

@@ -227,7 +227,6 @@ async function startServer(): Promise<void> {
227227
host: program.hostname,
228228
});
229229

230-
231230
winston.info(`starting webserver listening on ${program.hostname}:${port}`);
232231
await callback(httpServer.listen.bind(httpServer), port, program.hostname);
233232

0 commit comments

Comments
 (0)