bcgov/common-hosted-email-service

View on GitHub
app/src/components/validators.js

Summary

Maintainability
F
1 wk
Test Coverage
A
99%
const bytes = require('bytes');
const fs = require('fs');
const tmp = require('tmp');
const validator = require('validator');

const config = require('config');
const log = require('./log')(module.filename);
const { statusState } = require('./state');

const DEFAULT_ATTACHMENT_SIZE = bytes.parse('5mb');

const asyncForEach = async (array, callback) => {
  for (let index = 0; index < array.length; index++) {
    await callback(array[index], index, array);
  }
};

const models = {
  attachment: {
    /** @function content */
    content: value => {
      return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
    },

    /** @function contentType */
    contentType: value => {
      if (value) {
        return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
      }
      return true;
    },

    /** @function encoding */
    encoding: value => {
      if (value) {
        return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true }) && validator.isIn(value, ['base64', 'binary', 'hex']);
      }
      return true;
    },

    /** @function filename */
    filename: value => {
      return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
    },

    /** @function size */
    size: async (content, encoding, limit = DEFAULT_ATTACHMENT_SIZE) => {
      if (!(models.attachment.content(content) && models.attachment.encoding(encoding))) {
        return false;
      }

      let attachmentLimit = bytes.parse(limit);
      if (!attachmentLimit || isNaN(attachmentLimit) || attachmentLimit < 1) {
        return false;
      }

      // ok, looks like all incoming parameters are ok, check the size
      // write out temp file, if size is ok then return true...
      let tmpFile = undefined;

      try {
        tmpFile = tmp.fileSync();
        await fs.promises.writeFile(tmpFile.name, Buffer.from(content, encoding));
        // get the written file size
        const stats = fs.statSync(tmpFile.name);
        return stats.size <= attachmentLimit;
      } catch (e) {
        // something wrong (disk i/o?), cannot verify file size
        log.error(`Error validating file size. ${e.message}`, { function: 'size' });
        return false;
      } finally {
        // delete tmp file
        if (tmpFile) tmpFile.removeCallback();
      }
    }
  },

  context: {
    /** @function bcc */
    bcc: value => {
      return models.message.bcc(value);
    },

    /** @function cc */
    cc: value => {
      return models.message.cc(value);
    },

    /** @function delayTS */
    delayTS: value => {
      return models.message.delayTS(value);
    },

    /** @function keys */
    keys: obj => {
      if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
        return false;
      }
      // only pass an object if all keys in the object or child objects pass...
      let result = Object.keys(obj).every(k => {
        if (!Array.isArray(obj[k]) && obj[k] === Object(obj[k])) {
          return models.context.keys(obj[k]);
        }
        // only pass alphanumeric or underscore keys, fail anything else.
        return (/^\w+$/.test(k));
      });
      return result;
    },

    /** @function tag */
    tag: value => {
      return models.message.tag(value);
    },

    /** @function to */
    to: value => {
      return models.message.to(value);
    }
  },

  message: {
    /** @function bcc */
    bcc: value => {
      if (value) {
        return validatorUtils.isEmailList(value);
      }
      return true;
    },

    /** @function body */
    body: value => {
      return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
    },

    /** @function bodyType */
    bodyType: value => {
      return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true }) && validator.isIn(value, ['html', 'text']);
    },

    /** @function cc */
    cc: value => {
      if (value) {
        return validatorUtils.isEmailList(value);
      }
      return true;
    },

    /** @function delayTS */
    delayTS: value => {
      if (value) {
        return validatorUtils.isInt(value);
      }
      return true;
    },

    /** @function encoding */
    encoding: value => {
      if (value) {
        return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true }) && validator.isIn(value, ['base64', 'binary', 'hex', 'utf-8']);
      }
      return true;
    },

    /** @function from */
    from: value => {
      return validatorUtils.isEmail(value);
    },

    /** @function priority */
    priority: value => {
      if (value) {
        return validatorUtils.isString(value) && validator.isIn(value, ['normal', 'low', 'high']);
      }
      return true;
    },

    /** @function subject */
    subject: value => {
      return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
    },

    /** @function tag */
    tag: value => {
      if (value) {
        return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
      }
      return true;
    },

    /** @function to */
    to: value => {
      return validatorUtils.isEmailList(value) && value.length > 0;
    },

    /** @function validSender */
    validSender: value => {
      return validatorUtils.bcGovSenderRules(value);
    },

  },

  queryParams: {
    /** @function msgId */
    msgId: value => {
      if (value) {
        return validatorUtils.isString(value) && validator.isUUID(value);
      }
      return true;
    },

    /** @function status */
    status: value => {
      if (value) {
        return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true }) && Object.values(statusState).includes(value);
      }
      return true;
    },

    /** @function tag */
    tag: value => {
      if (value) {
        return validatorUtils.isString(value) && !validator.isEmpty(value, { ignore_whitespace: true });
      }
      return true;
    },

    /** @function txId */
    txId: value => {
      if (value) {
        return validatorUtils.isString(value) && validator.isUUID(value);
      }
      return true;
    }
  }
};

const validators = {
  attachments: async (obj, attachmentSizeLimit = DEFAULT_ATTACHMENT_SIZE) => {
    const errors = [];
    if (obj.attachments) {
      if (!Array.isArray(obj.attachments)) {
        errors.push({ value: undefined, message: 'Invalid value `attachments`. Expect an array of attachments.' });
      } else {
        // eslint-disable-next-line no-unused-vars
        await asyncForEach(obj.attachments, async (a, i, r) => {
          let validateSize = true;
          if (!models.attachment.filename(a['filename'])) {
            errors.push({ value: a['filename'], message: `Attachments[${i}] invalid value \`filename\`.` });
            validateSize = false;
          }
          if (!models.attachment.encoding(a['encoding'])) {
            errors.push({ value: a['encoding'], message: `Attachments[${i}] invalid value \`encoding\`.` });
            validateSize = false;
          }
          if (!models.attachment.contentType(a['contentType'])) {
            errors.push({ value: a['contentType'], message: `Attachments[${i}] invalid value \`contentType\`.` });
            validateSize = false;
          }
          if (!models.attachment.content(a['content'])) {
            errors.push({
              value: 'Attachment purposefully omitted',
              message: `Attachments[${i}] invalid value \`content\`.`
            });
            validateSize = false;
          }
          if (validateSize) {
            const validSize = await models.attachment.size(a.content, a.encoding, attachmentSizeLimit);
            if (!validSize) {
              errors.push({
                value: 'Attachment purposefully omitted',
                message: `Attachments[${i}] exceeds size limit of ${bytes.format(attachmentSizeLimit, 'MB')}.`
              });
            }
          }
        });
      }
    }
    return errors;
  },

  cancelMsg: param => {
    const errors = [];

    if (!param.msgId) {
      errors.push({ value: param.msgId, message: 'Missing value `msgId`.' });
    } else if (!models.queryParams.msgId(param.msgId)) {
      errors.push({ value: param.msgId, message: 'Invalid value `msgId`.' });
    }

    return errors;
  },

  cancelQuery: query => {
    const errors = [];

    if (!query || !Object.keys(query).some(param => validator.isIn(param, ['msgId', 'status', 'tag', 'txId']))) {
      errors.push({
        value: 'params',
        message: 'At least one of `msgId`, `status`, `tag` or `txId` must be defined.'
      });
    }

    validators.searchQueryFields(query).forEach(error => errors.push(error));

    return errors;
  },

  contexts: obj => {
    const errors = [];
    if (obj.contexts) {
      if (!Array.isArray(obj.contexts)) {
        errors.push({ value: undefined, message: 'Invalid value `contexts`. Expect an array of contexts.' });
      } else {
        obj.contexts.forEach((c, i) => {
          if (!models.context.to(c['to'])) {
            errors.push({ value: c['to'], message: `Contexts[${i}] invalid value \`to\`.` });
          }
          if (!models.context.cc(c['cc'])) {
            errors.push({ value: c['cc'], message: `Contexts[${i}] invalid value \`cc\`.` });
          }
          if (!models.context.bcc(c['bcc'])) {
            errors.push({ value: c['bcc'], message: `Contexts[${i}] invalid value \`bcc\`.` });
          }
          if (!models.context.tag(c['tag'])) {
            errors.push({ value: c['tag'], message: `Contexts[${i}] invalid value \`tag\`.` });
          }
          if (!models.context.delayTS(c['delayTS'])) {
            errors.push({ value: c['delayTS'], message: `Contexts[${i}] invalid value \`delayTS\`.` });
          }
          if (!c['context']) {
            // let's just return a separate error when context is not passed in...
            errors.push({ value: c['context'], message: `Contexts[${i}] invalid value \`context\`.` });
          } else if (!models.context.keys(c['context'])) {
            // and here we can just show error on improperly named keys.
            errors.push({
              value: c['context'],
              message: `Contexts[${i}] \`context\` is invalid. Names can only contain alphanumeric or underscore characters.`
            });
          }
        });
      }
    } else {
      errors.push({ value: undefined, message: 'Invalid value `contexts`. Contexts array not provided.' });
    }
    return errors;
  },

  email: async (obj, attachmentSizeLimit = DEFAULT_ATTACHMENT_SIZE) => {
    // validate the email object
    // completely valid object will return an empty array of errors.
    // an invalid object will return a populated array of errors.
    const errors = [];

    validators.messageFields(obj, errors);

    if (!models.message.to(obj['to'])) {
      errors.push({ value: obj['to'], message: 'Invalid value `to`.' });
    }
    if (!models.message.cc(obj['cc'])) {
      errors.push({ value: obj['cc'], message: 'Invalid value `cc`.' });
    }
    if (!models.message.bcc(obj['bcc'])) {
      errors.push({ value: obj['bcc'], message: 'Invalid value `bcc`.' });
    }
    if (!models.message.tag(obj['tag'])) {
      errors.push({ value: obj['tag'], message: 'Invalid value `tag`.' });
    }
    if (!models.message.delayTS(obj['delayTS'])) {
      errors.push({ value: obj['delayTS'], message: 'Invalid value `delayTS`.' });
    }
    const attachmentErrors = await validators.attachments(obj, attachmentSizeLimit);
    if (attachmentErrors) {
      attachmentErrors.forEach(x => errors.push(x));
    }

    return errors;
  },

  messageFields: (obj, errors) => {
    if (!models.message.from(obj['from'])) {
      errors.push({ value: obj['from'], message: 'Invalid value `from`.' });
    }
    if (!models.message.validSender(obj['from'])) {
      errors.push({ value: obj['from'], message: `Invalid value 'from'. '${obj['from']}' is not permitted.` });
    }
    if (!models.message.subject(obj['subject'])) {
      errors.push({ value: obj['subject'], message: 'Invalid value `subject`.' });
    }
    if (!models.message.bodyType(obj['bodyType'])) {
      errors.push({ value: obj['bodyType'], message: 'Invalid value `bodyType`.' });
    }
    if (!models.message.body(obj['body'])) {
      errors.push({ value: 'Body purposefully omitted', message: 'Invalid value `body`.' });
    }
    if (!models.message.encoding(obj['encoding'])) {
      errors.push({ value: obj['encoding'], message: 'Invalid value `encoding`.' });
    }
    if (!models.message.priority(obj['priority'])) {
      errors.push({ value: obj['priority'], message: 'Invalid value `priority`.' });
    }
  },

  merge: async (merge, attachmentSizeLimit = DEFAULT_ATTACHMENT_SIZE) => {
    // validate the merge object
    // completely valid object will return an empty array of errors.
    // an invalid object will return a populated array of errors.
    const errors = [];
    validators.messageFields(merge, errors);

    validators.contexts(merge).forEach(x => errors.push(x));

    const attachmentErrors = await validators.attachments(merge, attachmentSizeLimit);
    if (attachmentErrors) {
      attachmentErrors.forEach(x => errors.push(x));
    }

    return errors;
  },

  promoteMsg: param => {
    const errors = [];

    if (!param.msgId) {
      errors.push({ value: param.msgId, message: 'Missing value `msgId`.' });
    } else if (!models.queryParams.msgId(param.msgId)) {
      errors.push({ value: param.msgId, message: 'Invalid value `msgId`.' });
    }

    return errors;
  },

  promoteQuery: query => {
    const errors = [];

    if (!query || !Object.keys(query).some(param => validator.isIn(param, ['msgId', 'status', 'tag', 'txId']))) {
      errors.push({
        value: 'params',
        message: 'At least one of `msgId`, `status`, `tag` or `txId` must be defined.'
      });
    }

    validators.searchQueryFields(query).forEach(error => errors.push(error));

    return errors;
  },

  statusFetch: param => {
    const errors = [];

    if (!models.queryParams.msgId(param.msgId)) {
      errors.push({ value: param.msgId, message: 'Invalid value `msgId`.' });
    }

    return errors;
  },

  statusQuery: query => {
    const errors = [];

    if (!query || !Object.keys(query).some(param => validator.isIn(param, ['msgId', 'status', 'tag', 'txId']))) {
      errors.push({
        value: 'params',
        message: 'At least one of `msgId`, `status`, `tag` or `txId` must be defined.'
      });
    }

    validators.searchQueryFields(query).forEach(error => errors.push(error));

    return errors;
  },

  searchQueryFields: obj => {
    const errors = [];

    if (!models.queryParams.msgId(obj.msgId)) {
      errors.push({ value: obj.msgId, message: 'Invalid value `msgId`.' });
    }
    if (!models.queryParams.status(obj.status)) {
      errors.push({ value: obj.status, message: 'Invalid value `status`.' });
    }
    if (!models.queryParams.tag(obj.tag)) {
      errors.push({ value: obj.tag, message: 'Invalid value `tag`.' });
    }
    if (!models.queryParams.txId(obj.txId)) {
      errors.push({ value: obj.txId, message: 'Invalid value `txId`.' });
    }

    return errors;
  }
};

const validatorUtils = {
  /** @function isEmail */
  isEmail: x => {
    return validatorUtils.isString(x) && !validator.isEmpty(x, { ignore_whitespace: true }) && validator.isEmail(x, { allow_display_name: true });
  },

  /** @function isEmailList */
  isEmailList: x => {
    return x && Array.isArray(x) && x.every(v => {
      return validatorUtils.isEmail(v);
    });
  },

  /** @function isInt */
  isInt: x => {
    if (isNaN(x)) {
      return false;
    }
    const num = parseFloat(x);
    // use modulus to determine if it is an int
    return num % 1 === 0;
  },

  /** @function isString */
  isString: x => {
    return Object.prototype.toString.call(x) === '[object String]';
  },

  /** @function bcGovSenderRules */
  bcGovSenderRules: x => {
    if (config.has('server.bcGovSenderRules')) {
      const isBCGovSender = x.match(/^.*@(.*\.)*gov\.bc\.ca$/gi);
      const isDoNotReply = x.match(/^donotreply@gov\.bc\.ca$/gi);
      return !!(isBCGovSender && !isDoNotReply);
    }

    return true;
  }
};

module.exports = { models, validators, validatorUtils };