6dc1d08590
refs https://github.com/TryGhost/DevOps/issues/50 - when creating a TS config in our `eslint-plugin-ghost` dependency, I only extended the recommended config, which left out a lot of stylistic things we used to enforce in JS - this fixes that by bumping the dependency to a version which extends those shared configs, and fixes all the code that currently goes against those rules
170 lines
5.4 KiB
TypeScript
170 lines
5.4 KiB
TypeScript
import crypto from 'crypto';
|
|
import errors from '@tryghost/errors';
|
|
import tpl from '@tryghost/tpl';
|
|
|
|
import {MailEvent} from './MailEvent';
|
|
import {MailEventRepository} from './MailEventRepository';
|
|
|
|
/**
|
|
* @see https://documentation.mailgun.com/en/latest/user_manual.html#events-1
|
|
*/
|
|
enum EventType { // eslint-disable-line no-shadow
|
|
CLICKED = 'clicked',
|
|
COMPLAINED = 'complained',
|
|
DELIVERED = 'delivered',
|
|
FAILED = 'failed',
|
|
OPENED = 'opened',
|
|
UNSUBSCRIBED = 'unsubscribed'
|
|
}
|
|
|
|
interface PayloadEvent {
|
|
id: string;
|
|
timestamp: number; // Unix timestamp in seconds
|
|
event: string;
|
|
message: {
|
|
headers: {
|
|
'message-id': string;
|
|
}
|
|
},
|
|
recipient: string;
|
|
}
|
|
|
|
interface Payload {
|
|
signature: string;
|
|
mail_events: PayloadEvent[];
|
|
}
|
|
|
|
interface Labs {
|
|
isSet(key: string): boolean;
|
|
}
|
|
|
|
interface Config {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
get(key: string): any;
|
|
}
|
|
|
|
const VALIDATION_MESSAGES = {
|
|
signingKeyNotConfigured: 'payload signing key is not configured',
|
|
payloadSignatureMissing: 'Payload is missing "signature"',
|
|
payloadSignatureInvalid: '"signature" is invalid',
|
|
payloadEventsMissing: 'Payload is missing "mail_events"',
|
|
payloadEventsInvalid: '"mail_events" is not an array',
|
|
payloadEventKeyMissing: 'Event [{idx}] is missing "{key}"'
|
|
};
|
|
|
|
export class MailEventService {
|
|
static readonly LABS_KEY = 'mailEvents';
|
|
static readonly CONFIG_KEY_PAYLOAD_SIGNING_KEY = 'hostSettings:mailEventsPayloadSigningKey';
|
|
|
|
constructor(
|
|
private mailEventRepository: MailEventRepository,
|
|
private config: Config,
|
|
private labs: Labs
|
|
) {}
|
|
|
|
async processPayload(payload: Payload) {
|
|
if (this.labs.isSet(MailEventService.LABS_KEY) === false) {
|
|
throw new errors.NotFoundError();
|
|
}
|
|
|
|
const payloadSigningKey = this.config.get(MailEventService.CONFIG_KEY_PAYLOAD_SIGNING_KEY);
|
|
|
|
// Verify that the service is configured correctly - We expect a string
|
|
// for the payload signing key but as a safeguard we check the type here
|
|
// to prevent any unexpected behaviour
|
|
if (typeof payloadSigningKey !== 'string') {
|
|
throw new errors.InternalServerError({
|
|
message: tpl(VALIDATION_MESSAGES.signingKeyNotConfigured)
|
|
});
|
|
}
|
|
|
|
// Verify the payload
|
|
this.verifyPayload(payload, payloadSigningKey);
|
|
|
|
// Store known events
|
|
const eventTypes = new Set<string>(Object.values(EventType) as string[]);
|
|
|
|
for (const payloadEvent of payload.mail_events) {
|
|
if (eventTypes.has(payloadEvent.event) === false) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await this.mailEventRepository.save(
|
|
new MailEvent(
|
|
payloadEvent.id,
|
|
payloadEvent.event,
|
|
payloadEvent.message.headers['message-id'],
|
|
payloadEvent.recipient,
|
|
payloadEvent.timestamp * 1000
|
|
)
|
|
);
|
|
} catch (err) {
|
|
throw new errors.InternalServerError({
|
|
message: 'Event could not be stored',
|
|
err: err
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
validatePayload(payload: Payload) {
|
|
if (payload.signature === undefined) {
|
|
throw new errors.ValidationError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadSignatureMissing)
|
|
});
|
|
}
|
|
|
|
if (typeof payload.signature !== 'string') {
|
|
throw new errors.ValidationError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadSignatureInvalid)
|
|
});
|
|
}
|
|
|
|
if (payload.mail_events === undefined) {
|
|
throw new errors.ValidationError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadEventsMissing)
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(payload.mail_events) === false) {
|
|
throw new errors.ValidationError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadEventsInvalid)
|
|
});
|
|
}
|
|
|
|
const expectedKeys: (keyof PayloadEvent)[] = ['id', 'timestamp', 'event', 'message', 'recipient'];
|
|
|
|
payload.mail_events.forEach((payloadEvent, idx) => {
|
|
expectedKeys.forEach((key) => {
|
|
if (payloadEvent[key] === undefined) {
|
|
throw new errors.ValidationError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadEventKeyMissing, {idx, key})
|
|
});
|
|
}
|
|
|
|
if (key === 'message' && payloadEvent.message.headers?.['message-id'] === undefined) {
|
|
throw new errors.ValidationError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadEventKeyMissing, {idx, key: 'message.headers.message-id'})
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private verifyPayload(payload: Payload, payloadSigningKey: string) {
|
|
const data = JSON.stringify(payload.mail_events);
|
|
|
|
const signature = crypto
|
|
.createHmac('sha256', payloadSigningKey)
|
|
.update(data)
|
|
.digest('hex');
|
|
|
|
if (signature !== payload.signature) {
|
|
throw new errors.UnauthorizedError({
|
|
message: tpl(VALIDATION_MESSAGES.payloadSignatureInvalid)
|
|
});
|
|
}
|
|
}
|
|
}
|