TryGhost/Ghost

View on GitHub
ghost/mail-events/src/MailEventService.ts

Summary

Maintainability
A
3 hrs
Test Coverage
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)
            });
        }
    }
}