shootismoke/webapp

View on GitHub
src/backend/reports/email/main.ts

Summary

Maintainability
A
3 hrs
Test Coverage
/**
 * This file is part of Sh**t! I Smoke.
 *
 * Copyright (C) 2018-2021  Marcelo S. Coelho, Amaury M.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import { getCountryFromCode } from '@shootismoke/dataproviders';
import {
    fetchStationId,
    Frequency,
    frequencyToPeriod,
    getAQI,
    getPollutantData,
    getSwearWord,
    MongoUser,
    primaryPollutant,
    round,
} from '@shootismoke/ui';
import debug from 'debug';
import { config } from 'dotenv';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore I'm not sure why we need this line, if @types/form-data is installed
import formData from 'form-data';
import { readFileSync } from 'fs';
import { minify } from 'html-minifier';
import Mailgun from 'mailgun.js';
import { render } from 'mustache';

import { t } from '../../../frontend/localization';
import {
    City,
    getAllCities,
    rankClosestCities,
} from '../../../frontend/util/cities';
import { connectToDatabase } from '../../util';
import { findUsersForReport } from '../cron';
import { getMessageBody as getExpoMessage } from '../expo/expo';
import { tips } from './tips';

config({ path: '.env.staging' });

const l = debug('shootismoke:emailReport');

if (
    !process.env.BACKEND_MAILGUN_API_KEY ||
    !process.env.BACKEND_MAILGUN_DOMAIN
) {
    throw new Error(
        'Environment variables BACKEND_MAILGUN_{DOMAIN, API_KEY} need to be set'
    );
}

const mg = new Mailgun(formData);
const mgClient = mg.client({
    username: 'api',
    key: process.env.BACKEND_MAILGUN_API_KEY,
});

interface CreateMessageOpts {
    from: string;
    to: string;
    subject: string;
    text: string;
    html: string;
    'o:tag'?: string[];
}

/**
 * Depending on the user's frequency, calculate the number of cigarettes to
 * display.
 *
 * @param api - The Api object.
 * @param frequency - The user's frequency.
 */
function getDisplayedCigarettes(
    dailyCigarettes: number,
    frequency: Frequency
): number {
    return round(
        frequency === 'monthly'
            ? dailyCigarettes * 30
            : frequency === 'weekly'
            ? dailyCigarettes * 7
            : dailyCigarettes
    );
}

/**
 * Generate the body of the push notification message.
 */
function getEmailSubject(
    dailyCigarettes: number,
    frequency: Frequency,
    swearWord: string
): string {
    if (frequency === 'daily') {
        return `🚬 ${swearWord}! You'll smoke ${round(
            dailyCigarettes
        )} cigarettes today`;
    }

    return `🚬 ${swearWord}! You smoke ${getDisplayedCigarettes(
        dailyCigarettes,
        frequency
    )} cigarettes every ${frequencyToPeriod(frequency)}`;
}

/**
 * Craft an email for a user.
 *
 * @param user - The user to send the email to.
 */
async function emailForUser(
    user: MongoUser,
    cities: City[]
): Promise<CreateMessageOpts> {
    if (!user.emailReport) {
        throw new Error(
            `User ${user._id} has emailReport field per our query. qed.`
        );
    }

    const template = readFileSync(
        './src/backend/reports/email/template.html'
    ).toString('utf-8');

    const api = await fetchStationId(user.lastStationId);

    const cigarettes = getDisplayedCigarettes(
        api.shootismoke.dailyCigarettes,
        user.emailReport.frequency
    );
    const primaryPol = primaryPollutant(api.results) || api.results[0]; // We fallback to first value. FIXME.
    const aqi = getAQI(api.results) || api.results[0].value; // We fallback to first value. FIXME.
    const polData = getPollutantData(primaryPol.parameter);
    const swearWord = t(getSwearWord(cigarettes));
    const closestCities = (
        api.pm25.coordinates
            ? rankClosestCities(cities, api.pm25.coordinates, 5)
            : cities.slice(0, 5)
    ).map((city) => ({
        cigarettes: city.api?.shootismoke.dailyCigarettes
            ? `${getDisplayedCigarettes(
                    city.api.shootismoke.dailyCigarettes,
                    user.emailReport?.frequency || 'daily'
              )} cigarettes`
            : '0 cigarette',
        name: city.name
            ? [city.name, city.adminName, city.country]
                    .filter((x) => !!x)
                    .join(', ')
            : 'Unknown City',
    }));

    // Render template with mustache.
    const mustacheData = {
        closestCities,
        cigarettes,
        frequency: user.emailReport.frequency,
        frequencyPeriod: frequencyToPeriod(user.emailReport.frequency),
        location:
            [
                api.pm25.city,
                getCountryFromCode(api.pm25.country) || api.pm25.country,
            ]
                .filter((x) => !!x)
                .join(', ') ||
            api.pm25.location ||
            api.pm25.sourceName ||
            'Unknown City',
        pollutant: `${polData.name} (${primaryPol.parameter.toUpperCase()})`,
        swearWord,
        tips: tips(aqi),
        userId: user._id,
    };
    // We render the HTML using the mustach template and the data above. We
    // also minify the rendered HTML, so that it doesn't get bigger than 102KB.
    // https://mailchimp.com/help/gmail-is-clipping-my-email/
    const html = minify(render(template, mustacheData), {
        collapseBooleanAttributes: true,
        collapseInlineTagWhitespace: true,
        decodeEntities: true,
        minifyCSS: true,
        removeComments: true,
        removeAttributeQuotes: true,
        removeEmptyAttributes: true,
        removeEmptyElements: true,
        removeOptionalTags: true,
        removeRedundantAttributes: true,
        removeScriptTypeAttributes: true,
        removeStyleLinkTypeAttributes: true,
        removeTagWhitespace: true,
    });

    return {
        from: 'Marcelo <marcelo@shootismoke.app>',
        html,
        subject: getEmailSubject(
            api.shootismoke.dailyCigarettes,
            user.emailReport.frequency,
            swearWord
        ),
        // Fallback to the same message as the Expo push notification.
        text: getExpoMessage(
            api.shootismoke.dailyCigarettes,
            user.emailReport.frequency
        ),
        to: user.emailReport.email,
    };
}

/**
 * Main entry point of the script to send email reports to all users.
 */
export async function main(): Promise<void> {
    l('Starting email report script.');
    await connectToDatabase();
    // Fetch all users to whom we should send an email report.
    const users = await findUsersForReport('email');
    l('Found %d users to send emails to.', users.length);

    // If you wish to test, uncomment the following lines and fill out your
    // info.
    // users.push({
    //     _id: 'foo',
    //     lastStationId: 'aqicn|3092',
    //     emailReport: {
    //         email: 'amaury@shootismoke.app',
    //         frequency: 'weekly',
    //     },
    //     timezone: 'Europe/Berlin',
    // });

    const cities = await getAllCities();

    // TODO: To not spam OpenAQ, WAQI and Mailgun servers too hard at once,
    // should we spread the requests a little bit (by chunks, or with a simple
    // queue)?
    const emails = await Promise.allSettled(
        users.map(async (user) => {
            const email = await emailForUser(user, cities);

            return mgClient.messages.create(
                process.env.BACKEND_MAILGUN_DOMAIN as string,
                email
            );
        })
    );

    const rejected = emails.filter((p) => p.status === 'rejected');
    if (rejected.length) {
        l(`${rejected.length}/${users.length} rejected:`);
        l(rejected);
    }

    const successful = emails.filter((p) => p.status === 'fulfilled');
    l(`Sent ${successful.length}/${users.length} successful emails.`);
}