[CORL-498, CORL-495, CORL-539, CORL-496, CORL-494] Email Notifications Support & Framework (#2498)

* chore: renamed old templates

* feat: initial notifications support

* feat: email enhancements

* fix: linting

* feat: initial digesting beheviour

* feat: added notification configuration

* feat: added unsubscribe routes

* fix: fixed failing snapshots/tests bc random ids

* feat: adjusted the save beheviour, added tests

* feat: added tests

* feat: added staff replies

* feat: renamed E-Mail to Email

* feat: enhanced cron processing

* fix: linting + updating tests

* feat: enhanced cron context

* fix: added staff replies back in
This commit is contained in:
Wyatt Johnson
2019-09-05 07:02:26 +00:00
committed by GitHub
parent 0fad1070a6
commit efea0e8e1c
111 changed files with 3439 additions and 382 deletions
@@ -1,3 +1,4 @@
export * from "./confirm";
export * from "./invite";
export * from "./download";
export * from "./notifications";
@@ -0,0 +1,142 @@
import { AppOptions } from "coral-server/app";
import { RequestLimiter } from "coral-server/app/request/limiter";
import { updateUserNotificationSettings } from "coral-server/models/user";
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/categories/unsubscribe";
import { RequestHandler } from "coral-server/types/express";
export type UnsubscribeCheckOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis" | "config"
>;
export const unsubscribeCheckHandler = ({
redis,
mongo,
signingConfig,
config,
}: UnsubscribeCheckOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
max: 10,
prefix: "ip",
config,
});
const subLimiter = new RequestLimiter({
redis,
ttl: "5m",
max: 10,
prefix: "sub",
config,
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
// TODO: evaluate verifying if the Tenant allows verifications to short circuit.
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Verify the token.
await verifyUnsubscribeTokenString(
mongo,
tenant,
signingConfig,
tokenString,
coral.now
);
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
export type UnsubscribeOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis" | "config"
>;
export const unsubscribeHandler = ({
redis,
mongo,
signingConfig,
config,
}: UnsubscribeOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
max: 10,
prefix: "ip",
config,
});
const subLimiter = new RequestLimiter({
redis,
ttl: "5m",
max: 10,
prefix: "sub",
config,
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Verify the token.
const { user } = await verifyUnsubscribeTokenString(
mongo,
tenant,
signingConfig,
tokenString,
coral.now
);
// Unsubscribe the user from all notification types.
await updateUserNotificationSettings(mongo, tenant.id, user.id, {
onFeatured: false,
onModeration: false,
onReply: false,
onStaffReplies: false,
});
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
@@ -107,7 +107,7 @@ export const forgotHandler = ({
// Add the email to the processing queue.
await mailerQueue.add({
template: {
name: "forgot-password",
name: "account-notification/forgot-password",
context: {
resetURL,
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
@@ -19,6 +19,7 @@ export type GraphMiddlewareOptions = Pick<
| "pubsub"
| "tenantCache"
| "metrics"
| "notifierQueue"
>;
export const graphQLHandler = ({
+3 -1
View File
@@ -14,6 +14,7 @@ import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { createPassport } from "coral-server/app/middleware/passport";
import { Config } from "coral-server/config";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
@@ -35,9 +36,10 @@ export interface AppOptions {
mailerQueue: MailerQueue;
metrics?: Metrics;
mongo: Db;
notifierQueue: NotifierQueue;
parent: Express;
persistedQueryCache: PersistedQueryCache;
persistedQueriesRequired: boolean;
persistedQueryCache: PersistedQueryCache;
pubsub: RedisPubSub;
redis: AugmentedRedis;
schema: GraphQLSchema;
@@ -10,6 +10,8 @@ import {
confirmRequestHandler,
inviteCheckHandler,
inviteHandler,
unsubscribeCheckHandler,
unsubscribeHandler,
} from "coral-server/app/handlers";
import { jsonMiddleware } from "coral-server/app/middleware/json";
import { authenticate } from "coral-server/app/middleware/passport";
@@ -33,6 +35,9 @@ export function createNewAccountRouter(
router.get("/invite", inviteCheckHandler(app));
router.put("/invite", jsonMiddleware, inviteHandler(app));
router.get("/notifications/unsubscribe", unsubscribeCheckHandler(app));
router.delete("/notifications/unsubscribe", unsubscribeHandler(app));
router.get("/download", accountDownloadCheckHandler(app));
router.post(
"/download",
+104 -86
View File
@@ -1,19 +1,23 @@
import { CronCommand, CronJob } from "cron";
import { DateTime } from "luxon";
import { Collection, Db } from "mongodb";
import logger from "coral-server/logger";
import { CommentAction } from "coral-server/models/action/comment";
import { createCollection } from "coral-server/models/helpers";
import { Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import TenantCache from "coral-server/services/tenant/cache";
import {
ScheduledJob,
ScheduledJobCommand,
ScheduledJobGroup,
} from "./scheduled";
const BATCH_SIZE = 500;
// TODO: extract this out to a separate file so it
// can be re-used elsewhere
// TODO: extract this out to a separate file so it can be re-used elsewhere
const collections = {
users: createCollection<User>("users"),
comments: createCollection<Comment>("comments"),
@@ -22,80 +26,75 @@ const collections = {
commentActions: createCollection<CommentAction>("commentActions"),
};
interface Options {
mongo: Db;
mailerQueue: MailerQueue;
tenantCache: TenantCache;
}
export const NAME = "Account Deletion";
export function registerAccountDeletion(
mongo: Db,
mailer: MailerQueue
): CronJob {
const job = new CronJob({
options: Options
): ScheduledJobGroup<Options> {
const job = new ScheduledJob(options, {
name: `Twice Hourly ${NAME}`,
cronTime: "0,30 * * * *",
timeZone: "America/New_York",
start: true,
runOnInit: false,
onTick: deleteScheduledAccounts(mongo, mailer),
command: deleteScheduledAccounts,
});
if (job.running) {
logger.info("account deletion scheduler now running");
}
return job;
return { name: NAME, schedulers: [job] };
}
function deleteScheduledAccounts(mongo: Db, mailer: MailerQueue): CronCommand {
return async () => {
try {
logger.info("checking for accounts that require deletion");
const deleteScheduledAccounts: ScheduledJobCommand<Options> = async ({
log,
mongo,
mailerQueue,
tenantCache,
}) => {
// For each of the tenant's, process their users notifications.
for await (const tenant of tenantCache) {
log = log.child({ tenantID: tenant.id });
// TODO: iterate over tenants in tenant cache
while (true) {
const now = new Date();
const rescheduledDeletionDate = DateTime.fromJSDate(now)
.plus({ hours: 1 })
.toJSDate();
while (true) {
const now = new Date();
const rescheduledDeletionDate = DateTime.fromJSDate(now)
.plus({ hours: 1 })
.toJSDate();
const userResult = await collections.users(mongo).findOneAndUpdate(
{
scheduledDeletionDate: { $lte: now },
const { value: user } = await collections.users(mongo).findOneAndUpdate(
{
tenantID: tenant.id,
scheduledDeletionDate: { $lte: now },
},
{
$set: {
scheduledDeletionDate: rescheduledDeletionDate,
},
{
$set: {
scheduledDeletionDate: rescheduledDeletionDate,
},
},
{
// We want to get back the user with
// modified scheduledDeletionDate
returnOriginal: false,
}
);
if (!userResult.value) {
logger.info("no more users were scheduled for deletion");
break;
},
{
// We want to get back the user with
// modified scheduledDeletionDate
returnOriginal: false,
}
const userToDelete = userResult.value;
logger.info(
{ userID: userToDelete.id, tenantID: userToDelete.tenantID },
`deleting user`
);
deleteUser(mongo, mailer, userToDelete.id, userToDelete.tenantID, now);
}
} catch (error) {
logger.error(
{ error },
"an error occurred trying to perform scheduled account deletions"
);
if (!user) {
log.debug("no more users were scheduled for deletion");
break;
}
log.info({ userID: user.id }, "deleting user");
await deleteUser(mongo, mailerQueue, user.id, user.tenantID, now);
}
};
}
}
};
async function executeBulkOperations<T>(
collection: Collection<T>,
operations: any[]
) {
// TODO: (wyattjoh) fix types here to support actual types when upstream changes applied
const bulk: any = collection.initializeUnorderedBulkOp();
for (const operation of operations) {
@@ -110,7 +109,11 @@ interface Batch {
stories: any[];
}
async function deleteUserActionCounts(db: Db, userID: string) {
async function deleteUserActionCounts(
mongo: Db,
userID: string,
tenantID: string
) {
const batch: Batch = {
comments: [],
stories: [],
@@ -118,24 +121,30 @@ async function deleteUserActionCounts(db: Db, userID: string) {
async function processBatch() {
await executeBulkOperations<Comment>(
collections.comments(db),
collections.comments(mongo),
batch.comments
);
batch.comments = [];
await executeBulkOperations<Story>(collections.stories(db), batch.stories);
await executeBulkOperations<Story>(
collections.stories(mongo),
batch.stories
);
batch.stories = [];
}
const cursor = db
.collection("commentActions")
.find({ userID, actionType: "REACTION" });
const cursor = collections
.commentActions(mongo)
.find({ tenantID, userID, actionType: "REACTION" });
while (await cursor.hasNext()) {
const action = await cursor.next();
if (!action) {
continue;
}
batch.comments.push({
updateOne: {
filter: { id: action.commentID },
filter: { tenantID, id: action.commentID },
update: {
$inc: {
"revisions.$[revisions].actionCounts.REACTION": -1,
@@ -148,7 +157,7 @@ async function deleteUserActionCounts(db: Db, userID: string) {
batch.stories.push({
updateOne: {
filter: { id: action.storyID },
filter: { tenantID, id: action.storyID },
update: {
$inc: {
"commentCounts.action.REACTION": -1,
@@ -169,15 +178,20 @@ async function deleteUserActionCounts(db: Db, userID: string) {
await processBatch();
}
await collections.commentActions(db).deleteMany({
await collections.commentActions(mongo).deleteMany({
tenantID,
userID,
actionType: "REACTION",
});
}
async function deleteUserComments(db: Db, authorID: string) {
await collections.comments(db).updateMany(
{ authorID },
async function deleteUserComments(
mongo: Db,
authorID: string,
tenantID: string
) {
await collections.comments(mongo).updateMany(
{ tenantID, authorID },
{
$set: {
authorID: null,
@@ -190,29 +204,31 @@ async function deleteUserComments(db: Db, authorID: string) {
}
async function deleteUser(
db: Db,
mongo: Db,
mailer: MailerQueue,
userID: string,
tenantID: string,
now: Date
) {
const user = await collections.users(db).findOne({ id: userID, tenantID });
const user = await collections.users(mongo).findOne({ id: userID, tenantID });
if (!user) {
logger.warn({ userID, tenantID }, `could not find user`);
return;
throw new Error("could not find user by ID");
}
const tenant = await collections.tenants(db).findOne({ id: tenantID });
const tenant = await collections.tenants(mongo).findOne({ id: tenantID });
if (!tenant) {
logger.warn({ userID, tenantID }, `could not find tenant`);
return;
throw new Error("could not find tenant by ID");
}
await deleteUserActionCounts(db, userID);
await deleteUserComments(db, userID);
// Delete the user's action counts.
await deleteUserActionCounts(mongo, userID, tenantID);
collections.users(db).updateOne(
{ id: userID },
// Delete the user's comments.
await deleteUserComments(mongo, userID, tenantID);
// Mark the user as deleted.
await collections.users(mongo).updateOne(
{ tenantID, id: userID },
{
$set: {
profiles: [],
@@ -224,14 +240,16 @@ async function deleteUser(
}
);
// If the user has an email, then send them a confirmation that their account
// was deleted.
if (user.email) {
await mailer.add({
tenantID: tenant.id,
tenantID,
message: {
to: user.email,
},
template: {
name: "delete-request-completed",
name: "account-notification/delete-request-completed",
context: {
organizationContactEmail: tenant.organization.contactEmail,
organizationName: tenant.organization.name,
+27 -14
View File
@@ -1,27 +1,40 @@
import { CronJob } from "cron";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
import { registerAccountDeletion } from "./accountDeletion";
import { registerNotificationDigesting } from "./notificationDigesting";
export interface ScheduledTasks {
accountDeletion: ScheduledTask;
export interface ScheduledJobGroups {
accountDeletion: ReturnType<typeof registerAccountDeletion>;
notificationDigesting: ReturnType<typeof registerNotificationDigesting>;
}
export interface ScheduledTask {
name: string;
task: CronJob;
interface Options {
mongo: Db;
config: Config;
mailerQueue: MailerQueue;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export default function startScheduledTasks(
mongo: Db,
mailer: MailerQueue
): ScheduledTasks {
return {
accountDeletion: {
name: "Account Deletion",
task: registerAccountDeletion(mongo, mailer),
},
options: Options
): ScheduledJobGroups {
const tasks: ScheduledJobGroups = {
accountDeletion: registerAccountDeletion(options),
notificationDigesting: registerNotificationDigesting(options),
};
for (const { name, schedulers } of Object.values(tasks)) {
for (const scheduler of schedulers) {
scheduler.job.start();
scheduler.log.debug({ jobGroupName: name }, "now started job scheduling");
}
}
return tasks;
}
@@ -0,0 +1,131 @@
import { Db } from "mongodb";
import path from "path";
import { Config } from "coral-server/config";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/tenant/schema/__generated__/types";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { JWTSigningConfig } from "coral-server/services/jwt";
import NotificationContext from "coral-server/services/notifications/context";
import TenantCache from "coral-server/services/tenant/cache";
import {
ScheduledJob,
ScheduledJobCommand,
ScheduledJobGroup,
} from "./scheduled";
interface Options {
mongo: Db;
config: Config;
mailerQueue: MailerQueue;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export const NAME = "Notification Digesting";
export function registerNotificationDigesting(
options: Options
): ScheduledJobGroup<Options> {
const hourly = new ScheduledJob(options, {
name: `Hourly ${NAME}`,
cronTime: "0 * * * *",
command: processNotificationDigesting(GQLDIGEST_FREQUENCY.HOURLY),
});
const daily = new ScheduledJob(options, {
name: `Daily ${NAME}`,
cronTime: "0 0 * * *",
command: processNotificationDigesting(GQLDIGEST_FREQUENCY.DAILY),
});
return {
name: NAME,
schedulers: [hourly, daily],
};
}
/**
* DigestElement represents each element that is used for the digesting
* operations.
*/
interface DigestElement {
template: string;
partial: string;
contexts: Array<DigestibleTemplate["context"]>;
}
const processNotificationDigesting = (
frequency: GQLDIGEST_FREQUENCY
): ScheduledJobCommand<Options> => async ({
log,
mongo,
config,
signingConfig,
tenantCache,
mailerQueue,
}) => {
// For each of the tenant's, process their users notifications.
for await (const tenant of tenantCache) {
// Create a notification context to handle processing notifications. Note
// that this will share the current date for all users processed for this
// Tenant, but this is OK, because we're not using this Date for the
// digesting operations.
const ctx = new NotificationContext({
mongo,
config,
signingConfig,
tenant,
log,
});
ctx.log.debug("starting digesting for tenant");
// Process all the notifications for this Tenant.
for await (const user of ctx.digest(frequency)) {
ctx.log.debug(
{ userID: user.id, digests: user.digests.length },
"now processing digests for user"
);
// Group the digests.
const digests = user.digests.reduce(
(acc, entry) => {
const digest = acc.find(d => d.template === entry.template.name);
if (digest) {
digest.contexts.push(entry.template.context);
} else {
acc.push({
template: entry.template.name,
partial: path.basename(entry.template.name),
contexts: [entry.template.context],
});
}
return acc;
},
[] as DigestElement[]
);
// TODO: sort the digest template elements by the digest order.
// Add the email containing the digest information.
mailerQueue.add({
tenantID: tenant.id,
message: {
to: user.email!,
},
template: {
name: "notification/digest",
context: {
digests,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
unsubscribeURL: await ctx.generateUnsubscribeURL(user),
},
},
});
}
}
};
+6
View File
@@ -0,0 +1,6 @@
import { ScheduledJob } from "./job";
export interface ScheduledJobGroup<T> {
name: string;
schedulers: Array<ScheduledJob<T>>;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./group";
export * from "./job";
+55
View File
@@ -0,0 +1,55 @@
import { CronCommand, CronJob } from "cron";
import now from "performance-now";
import uuid from "uuid";
import logger, { Logger } from "coral-server/logger";
export type ScheduledJobCommand<T extends {}> = (
ctx: T & { log: Logger }
) => Promise<void>;
interface Options<T extends {}> {
name: string;
cronTime: string;
command: ScheduledJobCommand<T>;
}
export class ScheduledJob<T extends {} = {}> {
public readonly job: CronJob;
public readonly log: Logger;
public readonly context: T;
constructor(context: T, opts: Options<T>) {
this.context = context;
this.log = logger.child({
jobName: opts.name,
jobFrequency: opts.cronTime,
});
this.job = new CronJob({
cronTime: opts.cronTime,
onTick: this.command(opts.command),
timeZone: "America/New_York",
start: false,
runOnInit: false,
});
}
private command(command: ScheduledJobCommand<T>): CronCommand {
return async () => {
const log = this.log.child({ scheduledExecutionID: uuid.v1() });
log.debug("now starting scheduled job");
const start = now();
try {
await command({
...this.context,
log,
});
const processingTime = Math.floor(now() - start);
log.debug({ processingTime }, "now finished scheduled job");
} catch (err) {
const processingTime = Math.floor(now() - start);
log.error({ err, processingTime }, "failed to run scheduled job");
}
};
}
}
+4
View File
@@ -9,6 +9,7 @@ import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { JWTSigningConfig } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
@@ -20,6 +21,7 @@ export interface TenantContextOptions extends CommonContextOptions {
tenant: Tenant;
tenantCache: TenantCache;
mailerQueue: MailerQueue;
notifierQueue: NotifierQueue;
scraperQueue: ScraperQueue;
signingConfig?: JWTSigningConfig;
clientID?: string;
@@ -41,6 +43,7 @@ export default class TenantContext extends CommonContext {
constructor({
tenant,
logger: log = logger,
notifierQueue,
...options
}: TenantContextOptions) {
super({
@@ -57,6 +60,7 @@ export default class TenantContext extends CommonContext {
this.clientID = options.clientID;
this.publisher = createPublisher(
this.pubsub,
notifierQueue,
this.tenant.id,
this.clientID
);
@@ -21,6 +21,7 @@ import {
updateAvatar,
updateEmail,
updateEmailByID,
updateNotificationSettings,
updatePassword,
updateRole,
updateUsername,
@@ -46,6 +47,7 @@ import {
GQLSetUsernameInput,
GQLSuspendUserInput,
GQLUpdateEmailInput,
GQLUpdateNotificationSettingsInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
@@ -53,6 +55,7 @@ import {
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
import { WithoutMutationID } from "./util";
export const Users = (ctx: TenantContext) => ({
invite: async ({ role, emails }: GQLInviteUsersInput) =>
@@ -178,6 +181,9 @@ export const Users = (ctx: TenantContext) => ({
input.email,
input.password
),
updateNotificationSettings: async (
input: WithoutMutationID<GQLUpdateNotificationSettingsInput>
) => updateNotificationSettings(ctx.mongo, ctx.tenant, ctx.user!, input),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
@@ -17,10 +17,10 @@ import {
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
import { createConnection } from "coral-server/models/helpers";
import { getURLWithCommentID } from "coral-server/models/story";
import { getCommentEditableUntilDate } from "coral-server/services/comments";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
export const maybeLoadOnlyID = (
ctx: TenantContext,
@@ -25,6 +25,14 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
},
clientMutationId: input.clientMutationId,
}),
updateNotificationSettings: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
user: await ctx.mutators.Users.updateNotificationSettings(input),
clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.update(input),
clientMutationId: input.clientMutationId,
@@ -1,13 +1,22 @@
import { SubscriptionToCommentCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentCreatedInput extends SubscriptionPayload {
storyID: string;
commentID: string;
}
export type CommentCreatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
CommentCreatedInput
>;
export const commentCreated: SubscriptionToCommentCreatedResolver<
CommentCreatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_CREATED, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentEnteredModerationQueueInput
extends SubscriptionPayload {
@@ -13,6 +17,11 @@ export interface CommentEnteredModerationQueueInput
storyID: string;
}
export type CommentEnteredModerationQueueSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>;
export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerationQueueResolver<
CommentEnteredModerationQueueInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
queue: GQLMODERATION_QUEUE;
@@ -12,6 +16,11 @@ export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
storyID: string;
}
export type CommentLeftModerationQueueSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>;
export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueueResolver<
CommentLeftModerationQueueInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, {
@@ -1,13 +1,22 @@
import { SubscriptionToCommentReplyCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentReplyCreatedInput extends SubscriptionPayload {
ancestorIDs: string[];
commentID: string;
}
export type CommentReplyCreatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>;
export const commentReplyCreated: SubscriptionToCommentReplyCreatedResolver<
CommentReplyCreatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentStatusUpdatedInput extends SubscriptionPayload {
newStatus: GQLCOMMENT_STATUS;
@@ -13,6 +17,11 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload {
commentID: string;
}
export type CommentStatusUpdatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>;
export const commentStatusUpdated: SubscriptionToCommentStatusUpdatedResolver<
CommentStatusUpdatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, {
@@ -1,8 +1,8 @@
import { CommentCreatedInput } from "./commentCreated";
import { CommentEnteredModerationQueueInput } from "./commentEnteredModerationQueue";
import { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
import { CommentReplyCreatedInput } from "./commentReplyCreated";
import { CommentStatusUpdatedInput } from "./commentStatusUpdated";
import { CommentCreatedSubscription } from "./commentCreated";
import { CommentEnteredModerationQueueSubscription } from "./commentEnteredModerationQueue";
import { CommentLeftModerationQueueSubscription } from "./commentLeftModerationQueue";
import { CommentReplyCreatedSubscription } from "./commentReplyCreated";
import { CommentStatusUpdatedSubscription } from "./commentStatusUpdated";
export enum SUBSCRIPTION_CHANNELS {
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
@@ -25,23 +25,8 @@ export interface SubscriptionType<
}
export type SUBSCRIPTION_INPUT =
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
CommentCreatedInput
>;
| CommentEnteredModerationQueueSubscription
| CommentLeftModerationQueueSubscription
| CommentStatusUpdatedSubscription
| CommentReplyCreatedSubscription
| CommentCreatedSubscription;
@@ -1,9 +1,7 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "coral-common/utils";
import { constructTenantURL, reconstructURL } from "coral-server/app/url";
import TenantContext from "../context";
@@ -17,20 +15,6 @@ export function getRequestedFields<T>(info: GraphQLResolveInfo) {
return pull(Object.keys(graphqlFields<T>(info)), "__typename");
}
/**
* getURLWithCommentID returns the url with the comment id.
*
* @param storyURL url of the story
* @param commentID id of the comment
*/
export function getURLWithCommentID(storyURL: string, commentID?: string) {
const url = new URL(storyURL);
const query = parseQuery(url.search);
url.search = stringifyQuery({ ...query, commentID });
return url.toString();
}
export function reconstructTenantURLResolver<T = any>(path: string) {
return (parent: T, args: {}, ctx: TenantContext) => {
// If the request is available, then prefer it over building from the tenant
@@ -1088,11 +1088,11 @@ type CommenterAccountFeatures {
"""
changeUsername: Boolean!
"""
downloadComments when true, user may download their comment history
downloadComments when true, user may download their comment history
"""
downloadComments: Boolean!
"""
deleteAccount when true, non-sso user may permanently delete their account
deleteAccount when true, non-sso user may permanently delete their account
"""
deleteAccount: Boolean!
}
@@ -1535,6 +1535,60 @@ enum USER_STATUS {
SUSPENDED
}
enum DIGEST_FREQUENCY {
"""
NONE will have the notifications send immediatly rather than bundling for digesting.
"""
NONE
"""
DAILY will queue up the notifications and send them daily.
"""
DAILY
"""
HOURLY will queue up the notifications and send them hourly.
"""
HOURLY
}
"""
UserNotificationSettings stores the notification settings for a given User.
"""
type UserNotificationSettings {
"""
onReply, when true, will enable notifications to be sent to users that have
replies to their comments.
"""
onReply: Boolean!
"""
onFeatured, when true, will enable notifications to be sent to users that have
their comment's featured.
"""
onFeatured: Boolean!
"""
onStaffReplies when true, will enable notifications to be sent to users that
have a staff member reply to their comments. These notifications will
supercede notifications that would have been sent for a basic reply
notification.
"""
onStaffReplies: Boolean!
"""
onModeration when true, will enable notifications to be sent to users when a
comment that they wrote that was previously unpublished, becomes published due
to a moderator action.
"""
onModeration: Boolean!
"""
digestFrequency is the frequency to send notifications.
"""
digestFrequency: DIGEST_FREQUENCY!
}
"""
User is someone that leaves Comments, and logs in.
"""
@@ -1655,7 +1709,11 @@ type User {
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]!
@auth(roles: [ADMIN], userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(
roles: [ADMIN]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
ignoredUsers will return the list of ignored users.
@@ -1667,6 +1725,12 @@ type User {
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
notifications stores the notification settings for the given User.
"""
notifications: UserNotificationSettings!
@auth(userIDField: "id", permit: [SUSPENDED, BANNED])
"""
createdAt is the time that the User was created at.
"""
@@ -2503,6 +2567,61 @@ type Query {
## Mutations
################################################################################
##################
## updateNotificationSettings
##################
input UpdateNotificationSettingsInput {
"""
onReply, when true, will enable notifications to be sent to users that have
replies to their comments.
"""
onReply: Boolean
"""
onFeatured, when true, will enable notifications to be sent to users that have
their comment's featured.
"""
onFeatured: Boolean
"""
onStaffReplies when true, will enable notifications to be sent to users that
have a staff member reply to their comments. These notifications will
supercede notifications that would have been sent for a basic reply
notification.
"""
onStaffReplies: Boolean
"""
onModeration when true, will enable notifications to be sent to users when a
comment that they wrote that was previously unpublished, becomes published due
to a moderator action.
"""
onModeration: Boolean
"""
digestFrequency is the frequency to send notifications.
"""
digestFrequency: DIGEST_FREQUENCY
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateNotificationSettingsPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createComment
##################
@@ -3140,11 +3259,11 @@ input CommenterAccountFeaturesInput {
"""
changeUsername: Boolean
"""
downloadComments when true, user may download their comment history
downloadComments when true, user may download their comment history
"""
downloadComments: Boolean
"""
deleteAccount when true, non-sso user may permanently delete their account
deleteAccount when true, non-sso user may permanently delete their account
"""
deleteAccount: Boolean
}
@@ -4887,7 +5006,9 @@ type Mutation {
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION])
@auth(
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
)
"""
updateUsername will set the username on the current User if they have not set one
@@ -4959,6 +5080,14 @@ type Mutation {
"""
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload! @auth
"""
updateNotificationSettings can be used to update the notification settings for
the current logged in user.
"""
updateNotificationSettings(
input: UpdateNotificationSettingsInput!
): UpdateNotificationSettingsPayload! @auth
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.
@@ -5023,7 +5152,8 @@ type Mutation {
"""
requestCommentsDownload(
input: RequestCommentsDownloadInput!
): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
): RequestCommentsDownloadPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
"""
requestUserCommentsDownload allows a user to request to download their comments.
@@ -3,12 +3,13 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
import { createSubscriptionChannelName } from "coral-server/graph/tenant/resolvers/Subscription/helpers";
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
/**
* createPublisher will create a new Publisher that can be used to send events
* over the pubsub broker to facilitate live updates.
* over the pubsub broker to facilitate live updates and notifications.
*
* @param pubsub the pubsub broker to be used to facilitate the publish action
* @param tenantID the ID of the Tenant where the event will be published with
@@ -16,13 +17,23 @@ export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
*/
export const createPublisher = (
pubsub: RedisPubSub,
notifier: NotifierQueue,
tenantID: string,
clientID?: string
): Publisher => async ({ channel, payload }) => {
): Publisher => async input => {
const { channel, payload } = input;
logger.trace({ channel, tenantID, clientID }, "publishing event");
return pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
});
// Start the publishing operation out to all affected subscribers.
await Promise.all([
// Publish to the underlying pubsub system for subscriptions.
pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
}),
// Notify the notifications queue so we can offload notification processing
// to it.
notifier.add({ tenantID, input }),
]);
};
@@ -38,7 +38,7 @@ import { getOperationMetadata } from "coral-server/graph/common/extensions/helpe
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { userIsStaff } from "coral-server/models/user/helpers";
import { hasStaffRole } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";
import TenantContext, { TenantContextOptions } from "../context";
@@ -133,7 +133,7 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
// TODO: (wyattjoh) if the story settings can only disable, and not
// enable live updates (as it takes precedence over global settings)
// then we can add a check for `!tenant.live.enabled` here too.
if (!opts.user || !userIsStaff(opts.user)) {
if (!opts.user || !hasStaffRole(opts.user)) {
throw new LiveUpdatesDisabled();
}
}
+26 -9
View File
@@ -15,14 +15,17 @@ import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { accessLogger, errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import config, { Config } from "coral-server/config";
import startScheduledTasks, { ScheduledTasks } from "coral-server/cron";
import startScheduledTasks, { ScheduledJobGroups } from "coral-server/cron";
import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubsub";
import getTenantSchema from "coral-server/graph/tenant/schema";
import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server";
import logger from "coral-server/logger";
import { createQueue, TaskQueue } from "coral-server/queue";
import { I18n } from "coral-server/services/i18n";
import { createJWTSigningConfig } from "coral-server/services/jwt";
import {
createJWTSigningConfig,
JWTSigningConfig,
} from "coral-server/services/jwt";
import { createMetrics } from "coral-server/services/metrics";
import { createMongoDB } from "coral-server/services/mongodb";
import { ensureIndexes } from "coral-server/services/mongodb/indexes";
@@ -66,7 +69,8 @@ class Server {
// bind to the requested port to serve websocket traffic.
public subscriptionServer: SubscriptionServer;
public scheduledTasks: ScheduledTasks;
// scheduledTasks are tasks managed by cron.
public scheduledTasks: ScheduledJobGroups;
// tasks stores a reference to the queues that can process operations.
private tasks: TaskQueue;
@@ -92,6 +96,9 @@ class Server {
// i18n is the server reference to the i18n framework.
private i18n: I18n;
// signingConfig is the server reference to the signing configuration.
private signingConfig: JWTSigningConfig;
constructor(options: ServerOptions) {
this.parentApp = express();
@@ -110,6 +117,9 @@ class Server {
// Setup the translation framework.
this.i18n = new I18n(defaultLocale);
// Create the signing config.
this.signingConfig = createJWTSigningConfig(this.config);
}
/**
@@ -148,6 +158,7 @@ class Server {
mongo: this.mongo,
tenantCache: this.tenantCache,
i18n: this.i18n,
signingConfig: this.signingConfig,
});
// Create the pubsub client.
@@ -182,6 +193,16 @@ class Server {
// Launch all of the job processors.
this.tasks.mailer.process();
this.tasks.scraper.process();
this.tasks.notifier.process();
// Start up the cron job processors.
this.scheduledTasks = startScheduledTasks({
mongo: this.mongo,
config: this.config,
mailerQueue: this.tasks.mailer,
tenantCache: this.tenantCache,
signingConfig: this.signingConfig,
});
// If we are running in concurrency mode, and we are the master, we should
// setup the aggregator for the cluster metrics.
@@ -257,9 +278,6 @@ class Server {
// Ensure we have an app to bind to.
parent = parent ? parent : this.parentApp;
// Create the signing config.
const signingConfig = createJWTSigningConfig(this.config);
// Disables the client routes to serve bundles etc. Useful for developing with
// Webpack Dev Server.
const disableClientRoutes = this.config.get("disable_client_routes");
@@ -275,13 +293,14 @@ class Server {
pubsub: this.pubsub,
mongo: this.mongo,
redis: this.redis,
signingConfig,
signingConfig: this.signingConfig,
tenantCache: this.tenantCache,
config: this.config,
schema: this.schema,
i18n: this.i18n,
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
notifierQueue: this.tasks.notifier,
disableClientRoutes,
persistedQueryCache,
persistedQueriesRequired:
@@ -310,8 +329,6 @@ class Server {
);
logger.info({ port }, "now listening");
this.scheduledTasks = startScheduledTasks(this.mongo, this.tasks.mailer);
}
}
+47 -30
View File
@@ -1,66 +1,61 @@
email-notification-footer =
# Account Notifications
email-footer-accountNotification =
Sent by <a data-l10n-name="organizationLink">{ $organizationName }</a>
email-notification-template-forgotPassword =
email-subject-accountNotificationForgotPassword = Password Reset Request
email-template-accountNotificationForgotPassword =
Hello { $username },<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName">{ $organizationName }</a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
email-subject-forgotPassword = Password Reset Request
email-notification-template-ban =
email-subject-accountNotificationBan = Your account has been banned
email-template-accountNotificationBan =
{ $customMessage }<br /><br />
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-ban = Your account has been banned
email-notification-template-passwordChange =
email-subject-accountNotificationPasswordChange = Your password has been changed
email-template-accountNotificationPasswordChange =
Hello { $username },<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-passwordChange = Your password has been changed
email-subject-updateUsername = Your username has been changed
email-notification-template-updateUsername =
email-subject-accountNotificationUpdateUsername = Your username has been changed
email-template-accountNotificationUpdateUsername =
Hello { $username },<br/><br/>
Thank you for updating your { $organizationName } commenter account information. The changes you made are effective immediately. <br /><br />
If you did not make this change please reach out to our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-notification-template-suspend =
email-subject-accountNotificationSuspend = Your account has been suspended
email-template-accountNotificationSuspend =
{ $customMessage }<br/><br/>
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-suspend = Your account has been suspended
email-notification-template-confirmEmail =
email-subject-accountNotificationConfirmEmail = Confirm Email
email-template-accountNotificationConfirmEmail =
Hello { $username },<br/><br/>
To confirm your email address for use with your commenting account at { $organizationName },
please follow this link: <a data-l10n-name="confirmYourEmail">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{ $organizationName }, you can safely ignore this email.
email-subject-confirmEmail = Confirm Email
email-subject-invite = Coral Team invite
email-notification-template-invite =
email-subject-accountNotificationInvite = Coral Team invite
email-template-accountNotificationInvite =
You have been invited to join the { $organizationName } team on Coral. Finish
setting up your account <a data-l10n-name="invite">here</a>.
email-subject-downloadComments = Your comments are ready for download
email-notification-template-downloadComments =
email-subject-accountNotificationDownloadComments = Your comments are ready for download
email-template-accountNotificationDownloadComments =
Your comments from { $organizationName } as of { $date } are now available for download.<br /><br />
<a data-l10n-name="downloadUrl">Download my comment archive</a>
email-subject-deleteRequestConfirmation =
email-subject-accountNotificationDeleteRequestConfirmation =
Your commenter account is scheduled to be deleted
email-notification-template-deleteRequestConfirmation =
email-template-accountNotificationDeleteRequestConfirmation =
A request to delete your commenter account was received.
Your account is scheduled for deletion on { $requestDate }.<br /><br />
After that time all of your comments will be removed from the site,
@@ -69,15 +64,15 @@ email-notification-template-deleteRequestConfirmation =
If you change your mind you can sign into your account and cancel the
request before your scheduled account deletion time.
email-subject-deleteRequestCancel =
email-subject-accountNotificationDeleteRequestCancel =
Your account deletion request has been cancelled
email-notification-template-deleteRequestCancel =
email-template-accountNotificationDeleteRequestCancel =
You have cancelled your account deletion request for { $organizationName }.
Your account is now reactivated.
email-subject-deleteRequestCompleted =
email-subject-accountNotificationDeleteRequestCompleted =
Your account has been deleted
email-notification-template-deleteRequestCompleted =
email-template-accountNotificationDeleteRequestCompleted =
Your commenter account for { $organizationName } is now deleted. We're sorry to
see you go!<br /><br />
If you'd like to re-join the discussion in the future, you can sign up for
@@ -86,3 +81,25 @@ email-notification-template-deleteRequestCompleted =
the commenting experience better, please email us at
{ $organizationContactEmail }.
# Notification
email-footer-notification =
Sent by <a data-l10n-name="organizationLink">{ $organizationName }</a> - <a data-l10n-name="unsubscribeLink">Unsubscribe from these notifications</a>
## On Reply
email-subject-notificationOnReply = Someone has replied to your comment on { $organizationName }
email-template-notificationOnReply =
{ $organizationName } - <a data-l10n-name="storyLink">{ $storyTitle }</a><br /><br />
{ $authorUsername } has replied to your comment: <a data-l10n-name="commentPermalink">View comment</a>
## On Staff Reply
email-subject-notificationOnStaffReply = Someone at { $organizationName } has replied to your comment
email-template-notificationOnStaffReply =
{ $organizationName } - <a data-l10n-name="storyLink">{ $storyTitle }</a><br /><br/>
{ $authorUsername } works for { $organizationName } and has replied to your comment: <a data-l10n-name="commentPermalink">View comment</a>
# Notification Digest
email-subject-notificationDigest = Your latest comment activity at { $organizationName }
+14 -16
View File
@@ -1,47 +1,45 @@
email-notification-footer =
# Account Notifications
email-footer-accountNotification =
Enviado por <a data-l10n-name="organizationLink">{ $organizationName }</a>
email-notification-template-forgotPassword =
email-subject-accountNotificationForgotPassword = Pedido de Redefinição de Senha
email-template-accountNotificationForgotPassword =
Olá { $username },<br/><br/>
Recebemos uma solicitação para redefinir sua senha em <a data-l10n-name="organizationName">{ $organizationName }</a>.<br/><br/>
Por favor, use este link redefinir sua senha: <a data-l10n-name="resetYourPassword">Clique aqui para redefinir sua senha</a><br/><br/>
<i>Se você não solicitou isso, ignore este e-mail.</i><br/>
email-subject-forgotPassword = Pedido de Redefinição de Senha
email-notification-template-ban =
email-subject-accountNotificationBan = Sua conta foi banida
email-template-accountNotificationBan =
Olá { $username },<br/><br/>
Alguém com acesso à sua conta violou nossas diretrizes da comunidade.
Como resultado, sua conta foi banida. Você não será mais capaz de
comentar, reagir ou reportar comentários. se você acha que isso foi feito por engano,
entre em contato com nossa equipe da comunidade em <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-ban = Sua conta foi banida
email-notification-template-passwordChange =
email-subject-accountNotificationPasswordChange = Sua senha foi alterada
email-template-accountNotificationPasswordChange =
Olá { $username },<br/><br/>
A senha da sua conta foi alterada.<br/><br/>
Se você não solicitou essa alteração,
entre em contato com nossa equipe da comunidade em <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-passwordChange = Sua senha foi alterada
email-notification-template-suspend =
email-subject-accountNotificationSuspend = A sua conta foi suspensa
email-template-accountNotificationSuspend =
Olá { $username },<br/><br/>
Em concordância com as diretrizes da comunidade { $organizationName }, sua
  conta foi temporariamente suspensa. Durante a suspensão, você será
  incapaz de comentar, interagir ou se envolver com outros comentários. Por favor, junte-se a
  incapaz de comentar, interagir ou se envolver com outros comentários. Por favor, junte-se a
nós em { $until }.<br/><br/>
Se você acha que isso foi feito por engano, entre em contato com nossa equipe em
<a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-suspend = A sua conta foi suspensa
email-notification-template-confirmEmail =
email-subject-accountNotificationConfirmEmail = Confirmar e-mail
email-template-accountNotificationConfirmEmail =
Olá { $username },<br/><br/>
Para confirmar seu endereço de e-mail para usar sua conta nos comentários em { $organizationName }
<a data-l10n-name="confirmYourEmail">Clique aqui</a><br/><br/>
Se você não criou recentemente uma conta de comentários em
{ $organizationName }, você pode ignorar este email.
email-subject-confirmEmail = Confirmar e-mail
+17
View File
@@ -0,0 +1,17 @@
import { URL } from "url";
import { parseQuery, stringifyQuery } from "coral-common/utils";
/**
* getURLWithCommentID returns the url with the comment id.
*
* @param storyURL url of the story
* @param commentID id of the comment
*/
export function getURLWithCommentID(storyURL: string, commentID?: string) {
const url = new URL(storyURL);
const query = parseQuery(url.search);
url.search = stringifyQuery({ ...query, commentID });
return url.toString();
}
+1 -1
View File
@@ -26,8 +26,8 @@ import {
StoryCommentCounts,
} from "./counts";
// Export everything under counts.
export * from "./counts";
export * from "./helpers";
const collection = createCollection<Story>("stories");
+1 -1
View File
@@ -14,7 +14,7 @@ export function roleIsStaff(role: GQLUSER_ROLE) {
return false;
}
export function userIsStaff(user: Pick<User, "role">) {
export function hasStaffRole(user: Pick<User, "role">) {
return roleIsStaff(user.role);
}
+161
View File
@@ -19,10 +19,12 @@ import {
} from "coral-server/errors";
import {
GQLBanStatus,
GQLDIGEST_FREQUENCY,
GQLSuspensionStatus,
GQLTimeRange,
GQLUSER_ROLE,
GQLUsernameStatus,
GQLUserNotificationSettings,
} from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import {
@@ -35,6 +37,7 @@ import {
resolveConnection,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { getLocalProfile, hasLocalProfile } from "./helpers";
@@ -268,6 +271,24 @@ export interface IgnoredUser {
createdAt: Date;
}
/**
* Digest is the actual digest entry that is created every time a digest is
* queued for aUser.
*/
export interface Digest {
/**
* template is a given digestable template that was generated during the
* notification processing phase. This contains the context typically provided
* to the individual notification emails that are sent.
*/
template: DigestibleTemplate;
/**
* createdAt is the date that the digest entry was created at.
*/
createdAt: Date;
}
/**
* User is someone that leaves Comments, and logs in.
*/
@@ -324,6 +345,17 @@ export interface User extends TenantResource {
*/
role: GQLUSER_ROLE;
/**
* notifications stores the notification settings for the given User.
*/
notifications: GQLUserNotificationSettings;
/**
* digests stores all the notification digests on the User that are scheduled
* to be sent out based on the User's notification preferences.
*/
digests: Digest[];
/**
* status stores the user status information regarding moderation state.
*/
@@ -442,6 +474,8 @@ export type InsertUserInput = Omit<
| "tokens"
| "status"
| "ignoredUsers"
| "notifications"
| "digests"
| "emailVerificationID"
| "createdAt"
> &
@@ -466,6 +500,14 @@ export async function insertUser(
suspension: { history: [] },
ban: { active: false, history: [] },
},
notifications: {
onReply: false,
onFeatured: false,
onModeration: false,
onStaffReplies: false,
digestFrequency: GQLDIGEST_FREQUENCY.NONE,
},
digests: [],
createdAt: now,
};
@@ -1975,3 +2017,122 @@ export async function setUserLastDownloadedAt(
return result.value;
}
export type NotificationSettingsInput = Partial<GQLUserNotificationSettings>;
export async function updateUserNotificationSettings(
mongo: Db,
tenantID: string,
id: string,
settings: NotificationSettingsInput
) {
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
},
{
$set: dotize({
notifications: settings,
}),
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the update operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* insertUserNotificationDigests will push the notification contexts onto the
* User so that notifications can now be queued.
*
* @param mongo the database to put the notification digests into
* @param tenantID the ID of the Tenant that this User exists on
* @param id the ID of the User to insert the digests onto
* @param templates the templates that represent the digests to be inserted
* @param now the current time
*/
export async function insertUserNotificationDigests(
mongo: Db,
tenantID: string,
id: string,
templates: DigestibleTemplate[],
now: Date
) {
// Form the templates into digests to be sent.
const digests: Digest[] = templates.map(template => ({
template,
createdAt: now,
}));
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
},
{
$push: {
digests: { $each: digests },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the update operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* pullUserNotificationDigests will pull notification digests for a given User
* so it can be added to the mailer queue.
*
* @param mongo the database to pull digests from
* @param tenantID the tenant ID to pull digests for
* @param frequency the frequency that we're scanning for to limit the digest
* operation
*/
export async function pullUserNotificationDigests(
mongo: Db,
tenantID: string,
frequency: GQLDIGEST_FREQUENCY
) {
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
"notifications.digestFrequency": frequency,
digests: { $ne: [] },
},
{ $set: { digests: [] } },
{
// True to return the original document instead of the updated document.
returnOriginal: true,
}
);
return result.value || null;
}
+12 -5
View File
@@ -2,15 +2,15 @@ import Queue from "bull";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { createMailerTask, MailerQueue } from "coral-server/queue/tasks/mailer";
import {
createScraperTask,
ScraperQueue,
} from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { createRedisClient } from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { createMailerTask, MailerQueue } from "./tasks/mailer";
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
import { createScraperTask, ScraperQueue } from "./tasks/scraper";
const createQueueOptions = async (
config: Config
): Promise<Queue.QueueOptions> => {
@@ -46,11 +46,13 @@ export interface QueueOptions {
config: Config;
tenantCache: TenantCache;
i18n: I18n;
signingConfig: JWTSigningConfig;
}
export interface TaskQueue {
mailer: MailerQueue;
scraper: ScraperQueue;
notifier: NotifierQueue;
}
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
@@ -61,10 +63,15 @@ export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
// Attach process functions to the various tasks in the queue.
const mailer = createMailerTask(queueOptions, options);
const scraper = createScraperTask(queueOptions, options);
const notifier = createNotifierTask(queueOptions, {
mailerQueue: mailer,
...options,
});
// Return the tasks + client.
return {
mailer,
scraper,
notifier,
};
}
+14 -7
View File
@@ -46,13 +46,20 @@ export default class Task<T, U = any> {
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ jobID: job.id, jobName: this.options.jobName },
"processing completed"
);
return promise;
try {
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ jobID: job.id, jobName: this.options.jobName },
"processing completed"
);
return promise;
} catch (err) {
logger.error({ err }, "failed to process job from queue");
throw err;
}
});
logger.trace(
@@ -0,0 +1,37 @@
# mailer
The mailer is responsible for rendering and translating all email messages sent
by Coral.
## Adding a new email
The first step to defining your new email is to add a new email template to
`src/core/server/queue/tasks/mailer/templates/index.ts`. There you can see how
other templates define their requirements, and how you ensure that you get the
required context.
### Templates
Depending on the type of email you're adding, you're likely adding a:
- _Account Notification_ - an email sent as a result of an account action by the
system or an administrator.
- _Notification_ - an email sent to a user based on a notification preference
they've enabled.
There are folders under `templates` for each of these. Use any of the templates
in those folders as an example to craft your own template.
### Translations
Once you've added a template, you need to add a translation into the `src/core/server/locales/${locale}/email.ftl`
file. There are two translation lines you need to add to support a new email:
- _Subject_ - the subject of the email to be sent. Keys for these translations
follow the form `email-subject-${camelCase(templateName)}`.
- For example, for the template name `account-notification/confirm-email`
the subject translation key would become `email-subject-accountNotificationConfirmEmail`.
- _Template_ - the translated body of the email to be sent. Keys for these
translations follow the form: `email-template-${camelCase(templateName)}`.
- For example, for the template name `account-notification/confirm-email`
the subject translation key would become `email-template-accountNotificationConfirmEmail`.
@@ -12,3 +12,7 @@ table {
padding: 10px;
background-color: whitesmoke;
}
.footer {
text-align: center;
}
@@ -7,7 +7,7 @@ import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import { Tenant } from "coral-server/models/tenant";
import { Template } from "./templates";
import { EmailTemplate } from "./templates";
// templateDirectory is the directory containing the email templates.
const templateDirectory = path.join(__dirname, "templates");
@@ -23,11 +23,11 @@ export interface MailerContentOptions {
* @param env the nunjucks rendering environment
* @param template the template to render
*/
function render(env: Environment, { name, context }: Template) {
function render(env: Environment, { name, context }: EmailTemplate) {
return new Promise<string>((resolve, reject) =>
env.render(
name + ".html",
{ context, name: camelCase(name) },
{ context, name: camelCase(name), baseName: path.basename(name) },
(err, html) => {
if (err) {
return reject(err);
@@ -90,7 +90,7 @@ export default class MailerContent {
*/
public async generateHTML(
tenant: Tenant,
template: Template
template: EmailTemplate
): Promise<string> {
// Get the environment to render with.
const env = this.getEnvironment(tenant);
+2 -2
View File
@@ -12,13 +12,13 @@ import {
MailerData,
MailProcessorOptions,
} from "./processor";
import { Template } from "./templates";
import { EmailTemplate } from "./templates";
export interface MailerInput {
message: {
to: string;
};
template: Template;
template: EmailTemplate;
tenantID: string;
}
@@ -1,4 +1,5 @@
import { Job } from "bull";
import createDOMPurify from "dompurify";
import { DOMLocalization } from "fluent-dom/compat";
import { FluentBundle } from "fluent/compat";
import { minify } from "html-minifier";
@@ -16,6 +17,7 @@ import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { Config } from "coral-server/config";
import { InternalError } from "coral-server/errors";
import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { I18n, translate } from "coral-server/services/i18n";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
@@ -98,6 +100,7 @@ function createMessageTranslator(i18n: I18n) {
* @param data data used to send the message
*/
return async (
tenant: Tenant,
templateName: string,
locale: LanguageCode,
fromAddress: string,
@@ -121,22 +124,35 @@ function createMessageTranslator(i18n: I18n) {
// Translate the bundle.
await loc.translateFragment(dom.window.document);
// TODO: (wyattjoh) strip the i18n attributes from the source.
// Grab the rendered HTML from the dom, and juice them.
if (!dom.window.document.documentElement) {
throw new Error("dom did not have a document element");
}
const translatedHTML = dom.window.document.documentElement.outerHTML;
// Configure the purification.
const purify = createDOMPurify<false>(dom.window);
// Strip the l10n attributes from the email HTML.
purify.sanitize(dom.window.document.documentElement, {
ALLOW_DATA_ATTR: false,
WHOLE_DOCUMENT: true,
SANITIZE_DOM: false,
RETURN_DOM: false,
ADD_TAGS: ["link"],
FORBID_TAGS: [],
FORBID_ATTR: [],
IN_PLACE: true,
});
// Juice the HTML to inline resources.
const html = await juiceHTML(translatedHTML);
const html = await juiceHTML(dom.serialize());
// Get the translated subject.
const subject = translate(
bundle,
templateName,
`email-subject-${camelCase(templateName)}`
`email-subject-${camelCase(templateName)}`,
{ organizationName: tenant.organization.name }
);
// Generate the text content of the message from the HTML.
@@ -231,6 +247,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
let message: Message;
try {
message = await translateMessage(
tenant,
data.templateName,
tenant.locale,
fromAddress,
@@ -0,0 +1,11 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationBan" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
Someone with access to your account has violated our community guidelines.
As a result, your account has been banned. You will no longer be able to
comment, react or report comments. If you think this has been done in error,
please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationConfirmEmail" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
To confirm your email address for use with your commenting account at {{ context.organizationName }},
please follow this link: <a data-l10n-name="confirmYourEmail" href="{{ context.confirmURL }}">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{{ context.organizationName }}, you can safely ignore this email.
</div>
{% endblock %}
@@ -1,4 +1,4 @@
{% extends "layouts/user-notification.html" %}
{% extends "layouts/account-notification.html" %}
{% block content %}
You have cancelled your account deletion request for {{ context.organizationName }}. Your account is now reactivated.
@@ -1,4 +1,4 @@
{% extends "layouts/user-notification.html" %}
{% extends "layouts/account-notification.html" %}
{% block content %}
Your commenter account for {{ context.organizationName }} is now deleted. We're sorry to see you go!
@@ -1,4 +1,4 @@
{% extends "layouts/user-notification.html" %}
{% extends "layouts/account-notification.html" %}
{% block content %}
A request to delete your commenter account was received. Your account is scheduled for deletion on {{ context.requestDate }}.
@@ -0,0 +1,9 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationDownloadComments" data-l10n-args="{{ context | dump }}">
Your comments from {{ context.organizationName }} as of {{ context.date }} are
now available for download.<br /><br />
<a data-l10n-name="downloadUrl" href="{{ context.downloadUrl }}">Download my comment archive</a>
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationForgotPassword" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName" href="{{ context.organizationURL }}"></a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword" href="{{ context.resetURL }}">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationInvite" data-l10n-args="{{ context | dump }}">
You have been invited to join the {{ context.organizationName }} team on Coral. Finish
setting up your account <a data-l10n-name="invite" href="{{ context.inviteURL }}">here</a>.
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationPasswordChange" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationSuspend" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
In accordance with {{ context.organizationName }}'s community guidelines, your
account has been temporarily suspended. During the suspension, you will be
unable to comment, flag or engage with fellow commenters. Please rejoin the
conversation {{ context.until }}.<br/><br/>
If you think this has been done in error, please contact our community team at
<a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationUpdateUsername" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br /><br />
Thank you for updating your {{ context.organizationName }} commenter account
information. The changes you made are effective immediately. If you did not make
this change please reach out to <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -1,9 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
Someone with access to your account has violated our community guidelines.
As a result, your account has been banned. You will no longer be able to
comment, react or report comments. If you think this has been done in error,
please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,9 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
To confirm your email address for use with your commenting account at {{ context.organizationName }},
please follow this link: <a data-l10n-name="confirmYourEmail" href="{{ context.confirmURL }}">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{{ context.organizationName }}, you can safely ignore this email.
{% endblock %}
@@ -1,7 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Your comments from {{ context.organizationName }} as of {{ context.date }} are
now available for download.<br /><br />
<a data-l10n-name="downloadUrl" href="{{ context.downloadUrl }}">Download my comment archive</a>
{% endblock %}
@@ -1,8 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName" href="{{ context.organizationURL }}"></a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword" href="{{ context.resetURL }}">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
{% endblock %}
@@ -1,9 +1,67 @@
interface Template<T extends string, U extends {}> {
interface EmailTemplate<T extends string, U extends {}> {
name: T;
context: U;
}
type UserNotificationContext<T extends string, U extends {}> = Template<
/**
* NotificationContext
*/
type NotificationContext<T extends string, U extends {}> = EmailTemplate<
T,
U & {
organizationURL: string;
organizationName: string;
unsubscribeURL: string;
}
>;
export type OnReplyTemplate = NotificationContext<
"notification/on-reply",
{
storyTitle: string;
storyURL: string;
authorUsername: string;
commentPermalink: string;
}
>;
export type OnStaffReplyTemplate = NotificationContext<
"notification/on-staff-reply",
{
storyTitle: string;
storyURL: string;
authorUsername: string;
commentPermalink: string;
}
>;
export type DigestibleTemplate = OnReplyTemplate | OnStaffReplyTemplate;
type DigestTemplate = NotificationContext<
"notification/digest",
{
digests: Array<{
/**
* partial stores the part of the filename that can be used to tie the
* given notification into a specific template.
*/
partial: string;
/**
* contexts is the array of all the contexts under this partial that
* should be used to create the digest context.
*/
contexts: Array<DigestibleTemplate["context"]>;
}>;
}
>;
/**
* AccountNotificationContext
*/
type AccountNotificationContext<T extends string, U extends {}> = EmailTemplate<
T,
U & {
organizationURL: string;
@@ -11,16 +69,16 @@ type UserNotificationContext<T extends string, U extends {}> = Template<
}
>;
export type ForgotPasswordTemplate = UserNotificationContext<
"forgot-password",
export type ForgotPasswordTemplate = AccountNotificationContext<
"account-notification/forgot-password",
{
username: string;
resetURL: string;
}
>;
export type BanTemplate = UserNotificationContext<
"ban",
export type BanTemplate = AccountNotificationContext<
"account-notification/ban",
{
username: string;
organizationContactEmail: string;
@@ -28,8 +86,8 @@ export type BanTemplate = UserNotificationContext<
}
>;
export type SuspendTemplate = UserNotificationContext<
"suspend",
export type SuspendTemplate = AccountNotificationContext<
"account-notification/suspend",
{
username: string;
until: string;
@@ -38,16 +96,16 @@ export type SuspendTemplate = UserNotificationContext<
}
>;
export type PasswordChangeTemplate = UserNotificationContext<
"password-change",
export type PasswordChangeTemplate = AccountNotificationContext<
"account-notification/password-change",
{
username: string;
organizationContactEmail: string;
}
>;
export type ConfirmEmailTemplate = UserNotificationContext<
"confirm-email",
export type ConfirmEmailTemplate = AccountNotificationContext<
"account-notification/confirm-email",
{
username: string;
confirmURL: string;
@@ -55,15 +113,15 @@ export type ConfirmEmailTemplate = UserNotificationContext<
}
>;
export type InviteEmailTemplate = UserNotificationContext<
"invite",
export type InviteEmailTemplate = AccountNotificationContext<
"account-notification/invite",
{
inviteURL: string;
}
>;
export type DownloadCommentsTemplate = UserNotificationContext<
"download-comments",
export type DownloadCommentsTemplate = AccountNotificationContext<
"account-notification/download-comments",
{
username: string;
date: string;
@@ -71,33 +129,37 @@ export type DownloadCommentsTemplate = UserNotificationContext<
}
>;
export type UpdateUsernameTemplate = UserNotificationContext<
"update-username",
export type UpdateUsernameTemplate = AccountNotificationContext<
"account-notification/update-username",
{
username: string;
organizationContactEmail: string;
}
>;
export type AccountDeletionConfirmation = UserNotificationContext<
"delete-request-confirmation",
export type AccountDeletionConfirmation = AccountNotificationContext<
"account-notification/delete-request-confirmation",
{
requestDate: string;
}
>;
export type AccountDeletionCancellation = UserNotificationContext<
"delete-request-cancel",
export type AccountDeletionCancellation = AccountNotificationContext<
"account-notification/delete-request-cancel",
{}
>;
export type AccountDeletionCompleted = UserNotificationContext<
"delete-request-completed",
export type AccountDeletionCompleted = AccountNotificationContext<
"account-notification/delete-request-completed",
{
organizationContactEmail: string;
}
>;
/**
* Templates
*/
type Templates =
| BanTemplate
| ConfirmEmailTemplate
@@ -109,6 +171,9 @@ type Templates =
| UpdateUsernameTemplate
| AccountDeletionConfirmation
| AccountDeletionCancellation
| AccountDeletionCompleted;
| AccountDeletionCompleted
| OnReplyTemplate
| OnStaffReplyTemplate
| DigestTemplate;
export { Templates as Template };
export { Templates as EmailTemplate };
@@ -1,6 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
You have been invited to join the {{ context.organizationName }} team on Coral. Finish
setting up your account <a data-l10n-name="invite" href="{{ context.inviteURL }}">here</a>.
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "layouts/base.html" %}
{% block footer %}
<div data-l10n-id="email-footer-accountNotification" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a>
</div>
{% endblock %}
@@ -9,6 +9,17 @@
<link href="assets/main.css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
<table>
<tr>
<td class="content">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td class="footer">
{% block footer %}{% endblock %}
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,11 @@
{% extends "layouts/base.html" %}
{% block content %}
{% include "notification/partials/" + baseName + ".html" %}
{% endblock %}
{% block footer %}
<div data-l10n-id="email-footer-notification" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a> - <a data-l10n-name="unsubscribeLink" href="{{ context.unsubscribeURL }}">Unsubscribe from these notifications</a>
</div>
{% endblock %}
@@ -1,16 +0,0 @@
{% extends "layouts/base.html" %}
{% block body %}
<table>
<tr>
<td class="content" data-l10n-id="email-notification-template-{{ name }}" data-l10n-args="{{ context | dump }}">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td align="center" data-l10n-id="email-notification-footer" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a>
</td>
</tr>
</table>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "layouts/notification.html" %}
{% block content %}
{% for digest in context.digests %}
{% for context in digest.contexts %}
{% include "notification/partials/" + digest.partial + ".html" %}
{% endfor %}
{% endfor %}
{% endblock %}
@@ -0,0 +1 @@
{% extends "layouts/notification.html" %}
@@ -0,0 +1 @@
{% extends "layouts/notification.html" %}
@@ -0,0 +1,4 @@
<div data-l10n-id="email-template-notificationOnReply" data-l10n-args="{{ context | dump }}">
{{ context.organizationName }} - <a data-l10n-name="storyLink" href="{{ context.storyURL }}">{{ context.storyTitle }}</a><br /><br/>
{{ context.authorUsername }} has replied to your comment: <a data-l10n-name="commentPermalink" href="{{ context.commentPermalink }}">View comment</a>
</div>
@@ -0,0 +1,4 @@
<div data-l10n-id="email-template-notificationOnStaffReply" data-l10n-args="{{ context | dump }}">
{{ context.organizationName }} - <a data-l10n-name="storyLink" href="{{ context.storyURL }}">{{ context.storyTitle }}</a><br /><br/>
{{ context.authorUsername }} works for {{ context.organizationName }} and has replied to your comment: <a data-l10n-name="commentPermalink" href="{{ context.commentPermalink }}">View comment</a>
</div>
@@ -1,8 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,13 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
In accordance with {{ context.organizationName }}'s community guidelines, your
account has been temporarily suspended. During the suspension, you will be
unable to comment, flag or engage with fellow commenters. Please rejoin the
conversation {{ context.until }}.<br/><br/>
If you think this has been done in error, please contact our community team at
<a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,7 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %} Hello {{ context.username }},<br /><br />
Thank you for updating your {{ context.organizationName }} commenter account
information. The changes you made are effective immediately. If you did not make
this change please reach out to <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -0,0 +1,71 @@
import Queue from "bull";
import { groupBy } from "lodash";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import Task from "coral-server/queue/Task";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import {
categories,
NotificationCategory,
} from "coral-server/services/notifications/categories";
import TenantCache from "coral-server/services/tenant/cache";
import { createJobProcessor, JOB_NAME, NotifierData } from "./processor";
export const createNotifierTask = (
queue: Queue.QueueOptions,
options: Options
) => new NotifierQueue(queue, options);
interface Options {
mongo: Db;
mailerQueue: MailerQueue;
config: Config;
tenantCache: TenantCache;
signingConfig: JWTSigningConfig;
}
/**
* NotifierQueue is designed to handle creating and queuing notifications
* that could be sent to users.
*/
export class NotifierQueue {
private registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
private task: Task<NotifierData>;
constructor(queue: Queue.QueueOptions, options: Options) {
// Notification categories have been grouped by their event name so that
// each event emitted need only access the associated notification once.
this.registry = groupBy(categories, "event") as Record<
SUBSCRIPTION_CHANNELS,
NotificationCategory[]
>;
this.task = new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor({ registry: this.registry, ...options }),
queue,
});
}
public async add(data: NotifierData) {
// Get all the handlers that are active for this channel.
const c = this.registry[data.input.channel];
if (!c || c.length === 0) {
logger.debug(
{ channel: data.input.channel },
"no notifications registered on this channel"
);
return;
}
return this.task.add(data);
}
public process() {
return this.task.process();
}
}
@@ -0,0 +1,43 @@
import { filterSuperseded, SupersededNotification } from "./messages";
describe("filterSuperseded", () => {
it("handles when there is no superseded notifications", () => {
const notifications: SupersededNotification[] = [
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "2" },
},
];
expect(notifications.filter(filterSuperseded)).toHaveLength(3);
});
it("handles when there is superseded notifications", () => {
const notifications: SupersededNotification[] = [
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "reply", supersedesCategories: [] },
notification: { userID: "1" },
},
{
category: { name: "reply", supersedesCategories: [] },
notification: { userID: "2" },
},
];
expect(notifications.filter(filterSuperseded)).toHaveLength(3);
});
});
@@ -0,0 +1,127 @@
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { NotificationCategory } from "coral-server/services/notifications/categories";
import NotificationContext from "coral-server/services/notifications/context";
import { Notification } from "coral-server/services/notifications/notification";
import { MailerQueue } from "../mailer";
import { DigestibleTemplate } from "../mailer/templates";
import { CategoryNotification } from "./processor";
/**
* SupersededNotification is a subset of `CategoryNotification` to provide a
* minimal implementation.
*/
export interface SupersededNotification {
category: Pick<
CategoryNotification["category"],
"name" | "supersedesCategories"
>;
notification: Pick<CategoryNotification["notification"], "userID">;
}
/**
* filterSuperseded will filter all the possible notifications and only send
* those notifications that are not superseded by another type of notification.
*/
export const filterSuperseded = (
{
category: { name },
notification: { userID: destinationUserID },
}: SupersededNotification,
index: number,
notifications: SupersededNotification[]
) =>
!notifications.some(
({
category: { supersedesCategories = [] },
notification: { userID: notificationUserID },
}) =>
// Only allow notifications to supersede another notification if that
// notification is also destined for the same user.
notificationUserID === destinationUserID &&
// If another notification that is destined for the same user also exists
// and declares that it supersedes this one, return true so we can filter
// this one from the list.
supersedesCategories.some(
supersededCategory => supersededCategory === name
)
);
export const handleHandlers = async (
ctx: NotificationContext,
categories: NotificationCategory[],
input: SUBSCRIPTION_INPUT
): Promise<CategoryNotification[]> => {
const notifications: Array<CategoryNotification | null> = await Promise.all(
categories.map(async category => {
const notification = await category.process(ctx, input.payload);
if (!notification) {
return null;
}
return { category, notification };
})
);
// Filter out the categories that don't have notifications.
return notifications.filter(
notification => notification !== null
) as CategoryNotification[];
};
/**
* processNewNotifications will handle notifications that are collected after
* an event hook. These notifications will be batched by user and optionally
* queued for digesting or sent immediately depending on the user's settings.
*/
export const processNewNotifications = async (
ctx: NotificationContext,
notifications: Notification[],
mailer: MailerQueue
) => {
// Group all the notifications by user.
const userNotifications: Record<string, DigestibleTemplate[]> = {};
for (const { userID, template } of notifications) {
if (userID in userNotifications) {
userNotifications[userID].push(template);
} else {
userNotifications[userID] = [template];
}
}
// Load all the user's profiles into the context.
await ctx.users.loadMany(Object.keys(userNotifications));
// Send all the notifications for each user.
for (const [userID, templates] of Object.entries(userNotifications)) {
// Get the user from the context (which should have been already loaded from
// before).
const user = await ctx.users.load(userID);
if (!user) {
logger.warn(
{ userID },
"attempted notification for user that wasn't found"
);
continue;
}
if (user.notifications.digestFrequency === GQLDIGEST_FREQUENCY.NONE) {
// Send the notifications for the user now, they don't have digesting
// enabled.
for (const template of templates) {
await mailer.add({
tenantID: ctx.tenant.id,
message: {
to: user.email!,
},
template,
});
}
} else {
// Queue up the notifications to be sent in the next user's digest.
await ctx.addDigests(user.id, templates);
}
}
};
@@ -0,0 +1,128 @@
import { Job } from "bull";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import {
SUBSCRIPTION_CHANNELS,
SUBSCRIPTION_INPUT,
} from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { NotificationCategory } from "coral-server/services/notifications/categories";
import NotificationContext from "coral-server/services/notifications/context";
import { Notification } from "coral-server/services/notifications/notification";
import TenantCache from "coral-server/services/tenant/cache";
import {
filterSuperseded,
handleHandlers,
processNewNotifications,
} from "./messages";
export const JOB_NAME = "notifications";
/**
* NotifierData stores the data used by the notification system.
*/
export interface NotifierData {
tenantID: string;
input: SUBSCRIPTION_INPUT;
}
interface Options {
mailerQueue: MailerQueue;
mongo: Db;
config: Config;
registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
tenantCache: TenantCache;
signingConfig: JWTSigningConfig;
}
/**
* CategoryNotification combines the category and notification's to collect the
* appropriate elements together that can be used for digesting purposes.
*/
export interface CategoryNotification {
category: NotificationCategory;
notification: Notification;
}
/**
* createJobProcessor creates the processor that is used to process the
* possible notifications and queueing them up in the mailer if they need to be
* sent.
*
* @param options options for the processor
*/
export const createJobProcessor = ({
mailerQueue,
mongo,
config,
registry,
tenantCache,
signingConfig,
}: Options) => {
return async (job: Job<NotifierData>) => {
const now = new Date();
// Pull the data out of the model.
const { tenantID, input } = job.data;
// Create a new logger to handle logging for this job.
const log = logger.child({
jobID: job.id,
jobName: JOB_NAME,
tenantID,
});
log.debug("starting to handle a notify operation");
try {
// Get all the handlers that are active for this channel.
const categories = registry[input.channel];
if (!categories || categories.length === 0) {
return;
}
// Grab the tenant from the cache.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
throw new Error("tenant not found with ID");
}
// Create a notification context to handle processing notifications.
const ctx = new NotificationContext({
mongo,
config,
signingConfig,
tenant,
now,
});
// For each of the handler's we need to process, we should iterate to
// generate their notifications.
let notifications = await handleHandlers(ctx, categories, input);
// Check to see if some of the other notifications that are queued
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
// Send all the notifications now.
await processNewNotifications(
ctx,
notifications.map(({ notification }) => notification),
mailerQueue
);
log.debug(
{ notifications: notifications.length },
"notifications handled"
);
} catch (err) {
log.error({ err }, "could not handle the notifications");
throw err;
}
};
};
@@ -39,7 +39,6 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
try {
await scrape(mongo, tenantID, storyID, storyURL);
log.debug("scraped the story");
} catch (err) {
log.error({ err }, "could not scrape the story");
@@ -0,0 +1,10 @@
import { NotificationCategory } from "./category";
import { reply } from "./reply";
import { staffReply } from "./staffReply";
/**
* categories stores all the notification categories in a flat list.
*/
const categories: NotificationCategory[] = [...reply, ...staffReply];
export default categories;
@@ -0,0 +1,46 @@
import {
SUBSCRIPTION_CHANNELS,
SUBSCRIPTION_INPUT,
} from "coral-server/graph/tenant/resolvers/Subscription/types";
import NotificationContext from "../context";
import { Notification } from "../notification";
/**
* NotificationCategory define the Category that is used to define a
* Notification type.
*/
export interface NotificationCategory {
/**
* name is the actual name of the notification that can be used to define the
* other category names that are superseded by this one.
*/
name: string;
/**
* process is the actual job processor. It accepts a input payload of the
* correct type and context to create the actual Notification to send.
*/
process: (
ctx: NotificationContext,
input: SUBSCRIPTION_INPUT["payload"]
) => Promise<Notification | null>;
/**
* event is the subscription event that when fired, will trigger this
* notification processor to be called.
*/
event: SUBSCRIPTION_CHANNELS;
/**
* digestOrder, when provided, allows the custom ordering of notifications in
* a digest.
*/
digestOrder: number;
/**
* supersedesCategories is the category names that this specific notification
* category supersedes.
*/
supersedesCategories?: string[];
}
@@ -0,0 +1,3 @@
export { default as categories } from "./categories";
export * from "./categories";
export * from "./category";
@@ -0,0 +1,115 @@
import { CommentReplyCreatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types";
import { hasPublishedStatus } from "coral-server/models/comment";
import { getURLWithCommentID } from "coral-server/models/story";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentReplyCreatedInput
): Promise<Notification | null> {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// Check to see if this is a reply to an existing comment.
if (!comment.parentID) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
return null;
}
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onReply) {
return null;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// Check to see if this user is ignoring the user who replied to their
// comment.
if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) {
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle:
story.metadata && story.metadata.title
? story.metadata.title
: story.url,
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
}
export const reply: NotificationCategory[] = [
{
name: "reply",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
digestOrder: 30,
},
{
name: "reply",
process: async (ctx, input: CommentStatusUpdatedInput) => {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// We've checked the status, let the processing continue!
return processor(ctx, {
commentID: comment.id,
ancestorIDs: comment.ancestorIDs,
});
},
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
digestOrder: 30,
},
];
@@ -0,0 +1,117 @@
import { CommentReplyCreatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types";
import { hasPublishedStatus } from "coral-server/models/comment";
import { getURLWithCommentID } from "coral-server/models/story";
import { hasStaffRole } from "coral-server/models/user/helpers";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentReplyCreatedInput
): Promise<Notification | null> {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// Check to see if this is a reply to an existing comment.
if (!comment.parentID) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
return null;
}
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if the author was a staff member.
if (!hasStaffRole(author)) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onStaffReplies) {
return null;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-staff-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle:
story.metadata && story.metadata.title
? story.metadata.title
: story.url,
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
}
export const staffReply: NotificationCategory[] = [
{
name: "staffReply",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
digestOrder: 30,
supersedesCategories: ["reply"],
},
{
name: "staffReply",
process: async (ctx, input: CommentStatusUpdatedInput) => {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// We've checked the status, let the processing continue!
return processor(ctx, {
commentID: comment.id,
ancestorIDs: comment.ancestorIDs,
});
},
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
digestOrder: 30,
supersedesCategories: ["reply"],
},
];
@@ -0,0 +1,124 @@
import Joi from "joi";
import { isNull } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import uuid from "uuid";
import { constructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
import { TokenInvalidError, UserNotFoundError } from "coral-server/errors";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUser, User } from "coral-server/models/user";
import {
JWTSigningConfig,
signString,
StandardClaims,
StandardClaimsSchema,
verifyJWT,
} from "coral-server/services/jwt";
export interface UnsubscribeToken extends Required<StandardClaims> {
// aud specifies `unsubscribe` as the audience to indicate that this is a
// unsubscribe token.
aud: "unsubscribe";
}
const UnsubscribeTokenSchema = StandardClaimsSchema.keys({
aud: Joi.string().only("unsubscribe"),
});
export function validateUnsubscribeToken(
token: UnsubscribeToken | object
): Error | null {
const { error } = Joi.validate(token, UnsubscribeTokenSchema, {
presence: "required",
});
return error || null;
}
export function isUnsubscribeToken(
token: UnsubscribeToken | object
): token is UnsubscribeToken {
return isNull(validateUnsubscribeToken(token));
}
export async function generateUnsubscribeURL(
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
user: Pick<User, "id">,
now: Date
) {
// Pull some stuff out of the user.
const { id } = user;
// Change the JS Date to a DateTime for ease of use.
const nowDate = DateTime.fromJSDate(now);
const nowSeconds = Math.round(nowDate.toSeconds());
// The expiry of this token is linked as 1 week after issuance.
const expiresAt = Math.round(nowDate.plus({ weeks: 1 }).toSeconds());
// Generate a token.
const token: UnsubscribeToken = {
jti: uuid.v4(),
iss: tenant.id,
sub: id,
exp: expiresAt,
iat: nowSeconds,
nbf: nowSeconds,
aud: "unsubscribe",
};
// Sign it with the signing config.
const tokenString = await signString(signingConfig, token);
// Generate the unsubscribe url.
return constructTenantURL(
config,
tenant,
`/account/notifications/unsubscribe#unsubscribeToken=${tokenString}`
);
}
export async function verifyUnsubscribeTokenString(
mongo: Db,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
) {
const token = verifyJWT(tokenString, signingConfig, now, {
// Verify that the token is for this Tenant.
issuer: tenant.id,
// Verify that this is a unsubscribe token based on the audience.
audience: "unsubscribe",
});
// Validate that this is indeed a unsubscribe token.
if (!isUnsubscribeToken(token)) {
// TODO: (wyattjoh) look into a way of pulling the error into this one
throw new TokenInvalidError(
tokenString,
"does not conform to the unsubscribe token schema"
);
}
// Unpack some of the token.
const { sub: userID, iss } = token;
// TODO: (wyattjoh) verify that the token has not been revoked.
// Check to see if this unsubscribe token has already verified this email.
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
if (iss !== tenant.id) {
throw new TokenInvalidError(tokenString, "invalid tenant");
}
// Now that we've verified that the token is valid, we're good to go!
return { token, user };
}
@@ -0,0 +1,164 @@
import DataLoader from "dataloader";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/tenant/schema/__generated__/types";
import logger, { Logger } from "coral-server/logger";
import { Comment, retrieveManyComments } from "coral-server/models/comment";
import { retrieveManyStories, Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import {
insertUserNotificationDigests,
pullUserNotificationDigests,
retrieveManyUsers,
User,
} from "coral-server/models/user";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { generateUnsubscribeURL } from "./categories/unsubscribe";
interface Options {
mongo: Db;
tenant: Tenant;
config: Config;
signingConfig: JWTSigningConfig;
now?: Date;
log?: Logger;
}
/**
* NotificationContext provides a caching layer used by the notifications to
* collect data to include in notifications to be sent.
*/
export default class NotificationContext {
private readonly mongo: Db;
private readonly signingConfig: JWTSigningConfig;
/**
* tenant is the tenant performing this particular operation.
*/
public readonly tenant: Tenant;
/**
* now is the current date.
*/
public readonly now: Date;
/**
* config is used when generating the unsubscribe url's.
*/
public readonly config: Config;
/**
* log is the context wrapped logger for this NotificationContext.
*/
public readonly log: Logger;
/**
* users is a `DataLoader` used to retrieve users efficiently.
*/
public readonly users: DataLoader<
string,
Readonly<User> | null
> = new DataLoader(userIDs =>
retrieveManyUsers(this.mongo, this.tenant.id, userIDs)
);
/**
* comments is a `DataLoader` used to retrieve comments efficiently.
*/
public readonly comments: DataLoader<
string,
Readonly<Comment> | null
> = new DataLoader(commentIDs =>
retrieveManyComments(this.mongo, this.tenant.id, commentIDs)
);
/**
* stories is a `DataLoader` used to retrieve stories efficiently.
*/
public readonly stories: DataLoader<
string,
Readonly<Story> | null
> = new DataLoader(storyIDs =>
retrieveManyStories(this.mongo, this.tenant.id, storyIDs)
);
constructor({
mongo,
tenant,
now = new Date(),
log = logger,
config,
signingConfig,
}: Options) {
this.mongo = mongo;
this.tenant = tenant;
this.now = now;
this.config = config;
this.signingConfig = signingConfig;
this.log = log.child({ tenantID: tenant.id });
}
/**
* generateUnsubscribeURL will generate a unsubscribe token.
*
* @param user the user to generate the unsubscribe token for
*/
public async generateUnsubscribeURL(user: Pick<User, "id">) {
return generateUnsubscribeURL(
this.tenant,
this.config,
this.signingConfig,
user,
this.now
);
}
/**
* addDigests will add the given templates to the User so that they will be
* digested.
*/
public async addDigests(userID: string, templates: DigestibleTemplate[]) {
const user = await insertUserNotificationDigests(
this.mongo,
this.tenant.id,
userID,
templates,
this.now
);
this.users.prime(user.id, user);
return user;
}
/**
* digest will return an `asyncIterator` that can be used to iterate over all
* the users on a Tenant that have digests available configured with the given
* frequency.
*
* @param frequency the frequency to get the digests for
*/
public digest(frequency: GQLDIGEST_FREQUENCY) {
// `this` isn't available inside the iterator function, so extract it here.
const { mongo, tenant } = this;
return {
async *[Symbol.asyncIterator]() {
while (true) {
const user = await pullUserNotificationDigests(
mongo,
tenant.id,
frequency
);
if (!user) {
break;
}
yield user;
}
},
};
}
}
@@ -0,0 +1,17 @@
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
/**
* Notification stores the data used to issue a given notification.
*/
export interface Notification {
/**
* userID is the ID of the user to send the notification for.
*/
userID: string;
/**
* template is the actual template/email data to use when sending the
* notification to the user.
*/
template: DigestibleTemplate;
}
+37
View File
@@ -171,6 +171,43 @@ export default class TenantCache {
this.primed = true;
}
/**
* Symbol.asyncIterator implements the asyncIterator interface for the
* TenantCache. This allows you to use the TenantCache as a asyncIterator with
* a `for await (const tenant of tenants) {}` pattern to iterate over all the
* tenant's on the cache. If the cache is cacheable, and not primed, the cache
* will be primed at the first async iteration process. If caching is
* disabled, then the tenants will bne loaded on demand and not persisted
* after the iteration.
*/
public async *[Symbol.asyncIterator]() {
// If the cache isn't primed, and caching is enabled, then prime the cache
// now, as this will increase performance dramatically.
if (!this.primed && this.cachingEnabled) {
await this.primeAll();
}
// If the tenant's are primed in the cache, then just use the count cache as
// the iteration source.
if (this.primed) {
for (const tenantID of this.tenantCountCache) {
const tenant = await this.tenantsByID.load(tenantID);
if (!tenant) {
continue;
}
yield tenant;
}
}
// Caching must be disabled, so just grab all the tenants for this node and
// iterate through each of them as we handle it.
const tenants = await retrieveAllTenants(this.mongo);
for (const tenant of tenants) {
yield tenant;
}
}
/**
* onMessage is fired every time the client gets a subscription event.
*/
@@ -1,6 +1,8 @@
import Joi from "joi";
import { isNull } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import uuid from "uuid";
import { constructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
@@ -24,8 +26,6 @@ import {
StandardClaimsSchema,
verifyJWT,
} from "coral-server/services/jwt";
import { Db } from "mongodb";
import uuid = require("uuid");
export interface ConfirmToken extends Required<StandardClaims> {
// aud specifies `confirm` as the audience to indicate that this is a confirm
@@ -109,7 +109,6 @@ export async function generateConfirmURL(
return constructTenantURL(
config,
tenant,
// TODO: (kiwi) verify that url is correct.
`/account/email/confirm#confirmToken=${token}`
);
}
@@ -233,7 +232,7 @@ export async function sendConfirmationEmail(
to: user.email,
},
template: {
name: "confirm-email",
name: "account-notification/confirm-email",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: user.username,
@@ -239,7 +239,7 @@ export async function invite(
// Send the invited user an email with the invite token.
await mailerQueue.add({
template: {
name: "invite",
name: "account-notification/invite",
context: {
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
+20 -9
View File
@@ -39,6 +39,7 @@ import {
ignoreUser,
insertUser,
InsertUserInput,
NotificationSettingsInput,
removeActiveUserSuspensions,
removeUserBan,
removeUserIgnore,
@@ -52,6 +53,7 @@ import {
suspendUser,
updateUserAvatar,
updateUserEmail,
updateUserNotificationSettings,
updateUserPassword,
updateUserRole,
updateUserUsername,
@@ -62,7 +64,7 @@ import {
getLocalProfile,
hasLocalProfile,
} from "coral-server/models/user/helpers";
import { userIsStaff } from "coral-server/models/user/helpers";
import { hasStaffRole } from "coral-server/models/user/helpers";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { sendConfirmationEmail } from "coral-server/services/users/auth";
@@ -300,7 +302,7 @@ export async function updatePassword(
to: updatedUser.email,
},
template: {
name: "password-change",
name: "account-notification/password-change",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: updatedUser.username!,
@@ -362,7 +364,7 @@ export async function requestAccountDeletion(
to: user.email,
},
template: {
name: "delete-request-confirmation",
name: "account-notification/delete-request-confirmation",
context: {
requestDate: formattedDate,
organizationName: tenant.organization.name,
@@ -392,7 +394,7 @@ export async function cancelAccountDeletion(
to: user.email,
},
template: {
name: "delete-request-cancel",
name: "account-notification/delete-request-cancel",
context: {
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
@@ -524,7 +526,7 @@ export async function updateUsername(
to: user.email,
},
template: {
name: "update-username",
name: "account-notification/update-username",
context: {
username: user.username!,
organizationName: tenant.organization.name,
@@ -763,7 +765,7 @@ export async function ban(
to: user.email,
},
template: {
name: "ban",
name: "account-notification/ban",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: user.username!,
@@ -839,7 +841,7 @@ export async function suspend(
to: updatedUser.email,
},
template: {
name: "suspend",
name: "account-notification/suspend",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: updatedUser.username!,
@@ -922,7 +924,7 @@ export async function ignore(
throw new UserNotFoundError(userID);
}
const userToBeIgnoredIsStaff = userIsStaff(targetUser);
const userToBeIgnoredIsStaff = hasStaffRole(targetUser);
if (userToBeIgnoredIsStaff) {
throw new UserCannotBeIgnoredError(userID);
}
@@ -999,7 +1001,7 @@ export async function requestCommentsDownload(
to: user.email,
},
template: {
name: "download-comments",
name: "account-notification/download-comments",
context: {
username: user.username!,
date: Intl.DateTimeFormat(tenant.locale).format(now),
@@ -1037,3 +1039,12 @@ export async function requestUserCommentsDownload(
return downloadUrl;
}
export async function updateNotificationSettings(
mongo: Db,
tenant: Tenant,
user: User,
settings: NotificationSettingsInput
) {
return updateUserNotificationSettings(mongo, tenant.id, user.id, settings);
}