diff --git a/app/controllers/api/v1/aliases.js b/app/controllers/api/v1/aliases.js deleted file mode 100644 index 2b3b0c8ec..000000000 --- a/app/controllers/api/v1/aliases.js +++ /dev/null @@ -1,36 +0,0 @@ -const ObjectID = require('bson-objectid'); -const _ = require('lodash'); -const pickOriginal = require('@ladjs/pick-original'); - -const { _domainJSON } = require('./domains'); -const { Aliases, Users } = require('#models'); -const toObject = require('#helpers/to-object'); - -function json(alias) { - const object = toObject(Aliases, alias); - object.user = toObject(Users, alias.user); - object.domain = ObjectID.isValid(alias.domain) - ? alias.domain - : _domainJSON(alias.domain); - // TODO: if alias is not on paid plan or !alias.has_recipient_verification - // then we should omit `alias.pending_recipients` and `alias.verified_recipients` - return pickOriginal( - object, - _.isFunction(alias.toObject) ? alias.toObject() : alias - ); -} - -async function list(ctx) { - ctx.body = Array.isArray(ctx.state.domain.aliases) - ? ctx.state.domain.aliases.map((alias) => json(alias)) - : []; -} - -async function retrieve(ctx) { - ctx.body = json(ctx.state.alias); -} - -module.exports = { - list, - retrieve -}; diff --git a/app/controllers/api/v1/domains.js b/app/controllers/api/v1/domains.js deleted file mode 100644 index 08a2d614b..000000000 --- a/app/controllers/api/v1/domains.js +++ /dev/null @@ -1,72 +0,0 @@ -const ForwardEmail = require('forward-email'); -const _ = require('lodash'); -const pickOriginal = require('@ladjs/pick-original'); - -const config = require('#config'); -const logger = require('#helpers/logger'); -const toObject = require('#helpers/to-object'); -const { Users, Aliases, Domains } = require('#models'); - -const app = new ForwardEmail({ - logger, - recordPrefix: config.recordPrefix, - srs: { secret: 'null' }, - redis: false -}); - -function json(domain) { - const object = toObject(Domains, domain); - // map max recipients per alias - if (object.max_recipients_per_alias === 0) - object.max_recipients_per_alias = app.config.maxForwardedAddresses; - // members - if (Array.isArray(domain.members)) - object.members = domain.members.map((m) => { - const member = _.isFunction(m.toObject) - ? m.toObject() - : { group: m.group }; - member.user = toObject(Users, m.user); - if (_.isFinite(m.alias_count)) member.alias_count = m.alias_count; - return member; - }); - // invites - if (Array.isArray(domain.invites)) - object.invites = domain.invites.map((i) => - _.isFunction(i.toObject) - ? i.toObject() - : new Domains().invites.create(i).toObject() - ); - // aliases - if (Array.isArray(domain.aliases)) - object.aliases = domain.aliases.map((a) => { - const alias = toObject(Aliases, a); - alias.user = toObject(Users, a.user); - alias.domain = json(a.domain); - if (_.isString(a.group)) alias.group = a.group; - return alias; - }); - return { - ...pickOriginal( - object, - _.isFunction(domain.toObject) ? domain.toObject() : domain - ), - // add a helper url - link: `${config.urls.web}/my-account/domains/${domain.name}` - }; -} - -async function list(ctx) { - const data = ctx.state.domains.map((d) => json(d)); - ctx.body = data; -} - -async function retrieve(ctx) { - const data = json(ctx.state.domain); - ctx.body = data; -} - -module.exports = { - list, - retrieve, - _domainJSON: json -}; diff --git a/app/controllers/api/v1/enforce-paid-plan.js b/app/controllers/api/v1/enforce-paid-plan.js deleted file mode 100644 index 1c9b40d6f..000000000 --- a/app/controllers/api/v1/enforce-paid-plan.js +++ /dev/null @@ -1,20 +0,0 @@ -const Boom = require('@hapi/boom'); - -async function enforcePaidPlan(ctx, next) { - if (!ctx.isAuthenticated()) - return ctx.throw(Boom.unauthorized(ctx.translateError('LOGIN_REQUIRED'))); - - if (ctx.state.user.plan === 'free') - return ctx.throw( - Boom.paymentRequired( - ctx.translateError( - 'PLAN_UPGRADE_REQUIRED', - ctx.state.l('/my-account/billing/upgrade?plan=enhanced_protection') - ) - ) - ); - - return next(); -} - -module.exports = enforcePaidPlan; diff --git a/app/controllers/api/v1/index.js b/app/controllers/api/v1/index.js deleted file mode 100644 index 28185ebad..000000000 --- a/app/controllers/api/v1/index.js +++ /dev/null @@ -1,31 +0,0 @@ -const aliases = require('./aliases'); -const domains = require('./domains'); -const enforcePaidPlan = require('./enforce-paid-plan'); -const log = require('./log'); -const lookup = require('./lookup'); -const maxForwardedAddresses = require('./max-forwarded-addresses'); -const paypal = require('./paypal'); -const port = require('./port'); -const restricted = require('./restricted'); -const selfTest = require('./self-test'); -const settings = require('./settings'); -const stripe = require('./stripe'); -const test = require('./test'); -const users = require('./users'); - -module.exports = { - aliases, - domains, - enforcePaidPlan, - log, - lookup, - maxForwardedAddresses, - paypal, - port, - restricted, - selfTest, - settings, - stripe, - test, - users -}; diff --git a/app/controllers/api/v1/log.js b/app/controllers/api/v1/log.js deleted file mode 100644 index 443d8d60d..000000000 --- a/app/controllers/api/v1/log.js +++ /dev/null @@ -1,26 +0,0 @@ -const auth = require('basic-auth'); -const parseLogs = require('parse-logs'); - -const policies = require('#helpers/policies'); - -async function parseLog(ctx) { - ctx.body = 'OK'; - try { - const log = parseLogs(ctx.request); - ctx.logger[log.meta.level](log.message, log.meta); - } catch (err) { - ctx.logger.error(err); - } -} - -async function checkToken(ctx, next) { - try { - await policies.ensureApiToken(ctx, next); - } catch (err) { - const credentials = auth(ctx.req); - if (typeof credentials !== 'undefined') ctx.logger.error(err); - return next(); - } -} - -module.exports = { parseLog, checkToken }; diff --git a/app/controllers/api/v1/lookup.js b/app/controllers/api/v1/lookup.js deleted file mode 100644 index e5be13ed9..000000000 --- a/app/controllers/api/v1/lookup.js +++ /dev/null @@ -1,157 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); -const RE2 = require('re2'); -const regexParser = require('regex-parser'); - -const config = require('#config'); -const Domains = require('#models/domain'); -const Users = require('#models/user'); -const Aliases = require('#models/alias'); - -const REGEX_FLAG_ENDINGS = ['/gi', '/ig', '/g', '/i', '/']; - -async function lookup(ctx) { - if (!isSANB(ctx.query.verification_record)) - return ctx.throw( - Boom.badRequest(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - const query = { - verification_record: ctx.query.verification_record, - plan: { $ne: 'free' } - }; - - // legacy compatibility - if (isSANB(ctx.query.domain)) query.name = ctx.query.domain; - - const domain = await Domains.findOne(query).lean().exec(); - - if (!domain) { - ctx.body = []; - return; - } - - const bannedUserIds = await Users.distinct('_id', { - [config.userFields.isBanned]: true - }); - - const aliases = await Aliases.find({ - domain: domain._id, - user: { $nin: bannedUserIds } - }) - .lean() - .exec(); - - if (aliases.length === 0) { - ctx.body = []; - return; - } - - const username = isSANB(ctx.query.username) - ? ctx.query.username.toLowerCase() - : false; - - if (domain.plan !== 'free') { - for (const alias of aliases) { - if (alias.has_recipient_verification) { - const recipients = []; - for (const recipient of alias.recipients) { - // eslint-disable-next-line max-depth - if (alias.verified_recipients.includes(recipient)) - recipients.push(recipient); - } - - alias.recipients = recipients; - } - } - } - - ctx.body = aliases - .filter((alias) => { - if (alias.recipients.length === 0) return false; - if (alias.name === '*') return true; - if (!username) return true; - - // - // regex is not supported on global vanity domains - // (this is noted of in the FAQ section regarding regex) - // (also the majority of this code is copied from the FE smtp server codebase) - // - if (!domain.is_global) { - // must start with / and end with /: and not have the same index for the last index - // forward-email=/^(support|info)$/:forwardemail+$1@gmail.com - // -> this would forward to forwardemail+support@gmail.com if email sent to support@ - - // it either ends with: - // "/gi:" - // "/ig:" - // "/g:" - // "/i:" - // "/:" - // - let lastIndex; - const hasTwoSlashes = alias.name.lastIndexOf('/') !== 0; - const startsWithSlash = alias.name.startsWith('/'); - if (startsWithSlash && hasTwoSlashes) { - for (const ending of REGEX_FLAG_ENDINGS) { - if ( - alias.name.lastIndexOf(ending) !== -1 && - alias.name.lastIndexOf(ending) !== 0 - ) { - lastIndex = ending; - break; - } - } - } - - // - // regular expression support - // - // (with added support for regex gi flags) - // - if (startsWithSlash && hasTwoSlashes && lastIndex) { - let parsedRegex = alias.name.slice( - 0, - Math.max(0, alias.name.lastIndexOf(lastIndex) + 1) - ); - - // add case insensitive flag since email addresses are case insensitive - if (lastIndex === '/g:' || lastIndex === '/:') parsedRegex += 'i'; - // - // `forward-email=/^(support|info)$/:forwardemail+$1@gmail.com` - // support@mydomain.com -> forwardemail+support@gmail.com - // - // `forward-email=/^(support|info)$/:forwardemail.net/$1` - // info@mydomain.com -> POST to forwardemail.net/info - // - // `forward-email=/Support/g:forwardemail.net` - // - // `forward-email=/SUPPORT/gi:forwardemail.net` - const regex = new RE2(regexParser(parsedRegex)); - return regex.test(username); - } - } - - if (username !== alias.name) return false; - return true; - }) - .map((alias) => { - // alias.name = "*" (wildcard catchall) otherwise an alias - // alias.is_enabled = "!" prefixed alias name - // alias.recipients = comma separated (split with a colon) - - // if the alias requires recipient verification - // then filter out recipients that haven't yet clicked - // the verification link required and sent - // (but if and only if the domain was not on free plan) - if (alias.name === '*') return alias.recipients.join(','); - - return alias.recipients - .map((recipient) => - alias.is_enabled ? `${alias.name}:${recipient}` : `!${alias.name}` - ) - .join(','); - }); -} - -module.exports = lookup; diff --git a/app/controllers/api/v1/max-forwarded-addresses.js b/app/controllers/api/v1/max-forwarded-addresses.js deleted file mode 100644 index 3cb15736c..000000000 --- a/app/controllers/api/v1/max-forwarded-addresses.js +++ /dev/null @@ -1,92 +0,0 @@ -const Boom = require('@hapi/boom'); -const ForwardEmail = require('forward-email'); -const isFQDN = require('is-fqdn'); -const isSANB = require('is-string-and-not-blank'); - -const logger = require('#helpers/logger'); -const config = require('#config'); -const Domains = require('#models/domain'); - -const app = new ForwardEmail({ - logger, - recordPrefix: config.recordPrefix, - srs: { secret: 'null' }, - redis: false -}); - -const HTTP_RETRY_ERROR_CODES = new Set([ - 'ETIMEDOUT', - 'ECONNRESET', - 'EADDRINUSE', - 'ECONNREFUSED', - 'EPIPE', - 'ENOTFOUND', - 'ENETUNREACH', - 'EAI_AGAIN' -]); - -async function maxForwardedAddresses(ctx) { - try { - if (!isSANB(ctx.query.domain) || !isFQDN(ctx.query.domain)) - throw Boom.badRequest(ctx.translateError('INVALID_FQDN')); - - // TXT lookup here to find `forward-email-site-verification` - // if a verification record was found, then look it up and if it's valid - // otherwise return default max count - try { - const records = await app.resolver( - ctx.query.domain, - 'TXT', - false, - ctx.client - ); - - const verifications = []; - - for (const element of records) { - const record = element.join('').trim(); // join chunks together - if (record.startsWith(`${app.config.recordPrefix}-site-verification=`)) - verifications.push( - record - .replace(`${app.config.recordPrefix}-site-verification=`, '') - .trim() - ); - } - - let { maxForwardedAddresses } = app.config; - - if (verifications.length > 0) { - if (verifications.length > 1) - ctx.logger.fatal( - ctx.translateError('SINGLE_VERIFICATION_RECORD_REQUIRED'), - { domain: ctx.query.domain } - ); - - const domain = await Domains.findOne({ - verification_record: { $in: verifications }, - plan: { $ne: 'free' }, - max_recipients_per_alias: { $gt: maxForwardedAddresses } - }) - .select('max_recipients_per_alias') - .lean() - .exec(); - - if (domain && domain.max_recipients_per_alias > maxForwardedAddresses) - maxForwardedAddresses = domain.max_recipients_per_alias; - } - - ctx.body = { max_forwarded_addresses: maxForwardedAddresses }; - } catch (err) { - // superagent inside of the smtp-server will retry on 408 error code - // therefore if it is a DNS error, then send that retry code - // otherwise send a bad request error with the error - if (err.code && HTTP_RETRY_ERROR_CODES.has(err.code)) - throw Boom.clientTimeout(err); - throw Boom.badRequest(err); - } - } catch (err) { - ctx.throw(err); - } -} - -module.exports = maxForwardedAddresses; diff --git a/app/controllers/api/v1/paypal.js b/app/controllers/api/v1/paypal.js deleted file mode 100644 index 645296a32..000000000 --- a/app/controllers/api/v1/paypal.js +++ /dev/null @@ -1,55 +0,0 @@ -const { promisify } = require('util'); - -const Boom = require('@hapi/boom'); -const _ = require('lodash'); - -const env = require('#config/env'); -const { paypal } = require('#helpers/paypal'); - -// -async function webhook(ctx) { - try { - const response = await promisify( - paypal.notification.webhookEvent.verify, - paypal.notification.webhookEvent - )(ctx.request.headers, ctx.request.body, env.PAYPAL_WEBHOOK_ID); - - // throw an error if something was wrong - if (!_.isObject(response) || response.verification_status !== 'SUCCESS') - throw Boom.badRequest(ctx.translateError('INVALID_PAYPAL_SIGNATURE')); - - // NOTE: for now we just manually email admins of every event - // (and manual edits can be made as needed) - - // return a response to acknowledge receipt of the event - ctx.body = { received: true }; - - ctx.logger.info('paypal webhook', { response }); - - /* - // email admins here - try { - await email({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `PayPal Webhook ${dayjs().format('M/D/YY h:mm A')}` - }, - locals: { - message: `
${JSON.stringify(
-            response,
-            null,
-            2
-          )}
` - } - }); - } catch (err) { - ctx.logger.fatal(err); - } - */ - } catch (err) { - ctx.throw(err); - } -} - -module.exports = webhook; diff --git a/app/controllers/api/v1/port.js b/app/controllers/api/v1/port.js deleted file mode 100644 index 9e97231bc..000000000 --- a/app/controllers/api/v1/port.js +++ /dev/null @@ -1,85 +0,0 @@ -const Boom = require('@hapi/boom'); -const ForwardEmail = require('forward-email'); -const isFQDN = require('is-fqdn'); -const isSANB = require('is-string-and-not-blank'); -const { isPort } = require('validator'); - -const logger = require('#helpers/logger'); -const config = require('#config'); -const Domains = require('#models/domain'); - -const app = new ForwardEmail({ - logger, - recordPrefix: config.recordPrefix, - srs: { secret: 'null' }, - redis: false -}); - -async function port(ctx) { - try { - if (!isSANB(ctx.query.domain) || !isFQDN(ctx.query.domain)) - throw Boom.badRequest(ctx.translateError('INVALID_FQDN')); - - // TXT lookup here to find `forward-email-site-verification` - // if a verification record was found, then look it up and if it's valid - // otherwise if use `forward-email-port` value if it exists and valid - // otherwise return port 25 - try { - const records = await app.resolver( - ctx.query.domain, - 'TXT', - false, - ctx.client - ); - - const verifications = []; - const ports = []; - let port = '25'; - - for (const element of records) { - const record = element.join('').trim(); // join chunks together - if (record.startsWith(`${app.config.recordPrefix}-site-verification=`)) - verifications.push( - record.replace(`${app.config.recordPrefix}-site-verification=`, '') - ); - - if (record.startsWith(`${app.config.recordPrefix}-port=`)) - ports.push(record.replace(`${app.config.recordPrefix}-port=`, '')); - } - - if (verifications.length > 0) { - if (verifications.length > 1) - throw Boom.badRequest( - ctx.translateError('SINGLE_VERIFICATION_RECORD_REQUIRED') - ); - - const domain = await Domains.findOne({ - verification_record: verifications[0], - plan: { $ne: 'free' } - }) - .lean() - .exec(); - - if (!domain) - throw Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')); - - port = domain.smtp_port; - } else if (ports.length > 0) { - if (ports.length > 1) - throw Boom.badRequest(ctx.translateError('MULTIPLE_PORT_RECORDS')); - port = ports[0]; - } - - if (!isPort(port)) - throw Boom.badRequest(ctx.translateError('INVALID_PORT')); - - ctx.body = { port }; - } catch (err) { - throw Boom.badRequest(err); - } - } catch (err) { - ctx.throw(err); - } -} - -module.exports = port; diff --git a/app/controllers/api/v1/restricted.js b/app/controllers/api/v1/restricted.js deleted file mode 100644 index 1e9257aef..000000000 --- a/app/controllers/api/v1/restricted.js +++ /dev/null @@ -1,22 +0,0 @@ -const Boom = require('@hapi/boom'); -const auth = require('basic-auth'); - -const env = require('#config/env'); - -async function restricted(ctx, next) { - const credentials = auth(ctx.req); - - if ( - typeof credentials === 'undefined' || - typeof credentials.name !== 'string' || - !credentials.name - ) - throw Boom.unauthorized(ctx.translateError('INVALID_API_CREDENTIALS')); - - if (!env.API_SECRETS.includes(credentials.name)) - throw Boom.unauthorized(ctx.translateError('INVALID_API_TOKEN')); - - return next(); -} - -module.exports = restricted; diff --git a/app/controllers/api/v1/self-test.js b/app/controllers/api/v1/self-test.js deleted file mode 100644 index cb40d7c04..000000000 --- a/app/controllers/api/v1/self-test.js +++ /dev/null @@ -1,76 +0,0 @@ -const os = require('os'); - -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); -const pMap = require('p-map'); -const { isEmail } = require('validator'); - -const email = require('#helpers/email'); -const { SelfTests } = require('#models'); - -const concurrency = os.cpus().length; - -async function selfTest(ctx) { - async function mapper(to) { - try { - await email({ - template: 'self-test', - message: { to } - }); - } catch (err) { - ctx.logger.error(err); - } - } - - try { - if (isSANB(ctx.request.body.emails)) - ctx.request.body.emails = [ctx.request.body.emails]; - - if ( - !_.isArray(ctx.request.body.emails) || - _.isEmpty(ctx.request.body.emails) - ) - throw Boom.badRequest(ctx.translateError('INVALID_EMAIL')); - - const emails = _.uniq( - ctx.request.body.emails.map((email) => email.toLowerCase()) - ); - - if (emails.some((email) => !isEmail(email))) - throw Boom.badRequest(ctx.translateError('INVALID_EMAIL')); - - // filter out emails that we've already sent - const distinctEmailsAlreadySent = await SelfTests.distinct('email', { - email: { $in: emails } - }); - - // mutate array and remove emails we've already sent - if (_.isArray(distinctEmailsAlreadySent)) - _.pullAll(emails, distinctEmailsAlreadySent); - - // if there were no emails to send then return early - if (emails.length === 0) { - ctx.body = 'OK'; - return; - } - - // - // TODO: we should store `sent_at` and build a queue out of this - // - - // store that we sent this in case parallel requests - await SelfTests.create(emails.map((email) => ({ email }))); - - pMap(emails, mapper, { concurrency }) - .then(() => {}) - .catch((err) => ctx.logger.error(err)); - - // send successful response - ctx.body = 'OK'; - } catch (err) { - ctx.throw(err); - } -} - -module.exports = selfTest; diff --git a/app/controllers/api/v1/settings.js b/app/controllers/api/v1/settings.js deleted file mode 100644 index f0df892fa..000000000 --- a/app/controllers/api/v1/settings.js +++ /dev/null @@ -1,129 +0,0 @@ -const Boom = require('@hapi/boom'); -const ForwardEmail = require('forward-email'); -const isFQDN = require('is-fqdn'); -const isSANB = require('is-string-and-not-blank'); -const { isPort } = require('validator'); - -const logger = require('#helpers/logger'); -const config = require('#config'); -const Domains = require('#models/domain'); - -const app = new ForwardEmail({ - logger, - recordPrefix: config.recordPrefix, - srs: { secret: 'null' }, - redis: false -}); - -const HTTP_RETRY_ERROR_CODES = new Set([ - 'ETIMEDOUT', - 'ECONNRESET', - 'EADDRINUSE', - 'ECONNREFUSED', - 'EPIPE', - 'ENOTFOUND', - 'ENETUNREACH', - 'EAI_AGAIN' -]); - -async function settings(ctx) { - try { - if (!isSANB(ctx.query.domain) || !isFQDN(ctx.query.domain)) - throw Boom.badRequest(ctx.translateError('INVALID_FQDN')); - - // TXT lookup here to find `forward-email-site-verification` - // if a verification record was found, then look it up and if it's valid - // otherwise if use `forward-email-port` value if it exists and valid - // otherwise return port 25 - try { - const records = await app.resolver( - ctx.query.domain, - 'TXT', - false, - ctx.client - ); - - const verifications = []; - const ports = []; - let port = '25'; - let hasAdultContentProtection = true; - let hasPhishingProtection = true; - let hasExecutableProtection = true; - let hasVirusProtection = true; - - for (const element of records) { - const record = element.join('').trim(); // join chunks together - if (record.startsWith(`${app.config.recordPrefix}-site-verification=`)) - verifications.push( - record.replace(`${app.config.recordPrefix}-site-verification=`, '') - ); - - if (record.startsWith(`${app.config.recordPrefix}-port=`)) - ports.push(record.replace(`${app.config.recordPrefix}-port=`, '')); - } - - if (verifications.length > 0) { - if (verifications.length > 1) - ctx.logger.error( - ctx.translateError('SINGLE_VERIFICATION_RECORD_REQUIRED') - ); - - const domain = await Domains.findOne({ - verification_record: verifications[0], - plan: { $ne: 'free' } - }) - .select( - 'smtp_port has_adult_content_protection has_phishing_protection has_executable_protection has_virus_protection' - ) - .lean() - .exec(); - - if (domain) { - port = domain.smtp_port; - hasAdultContentProtection = domain.has_adult_content_protection; - hasPhishingProtection = domain.has_phishing_protection; - hasExecutableProtection = domain.has_executable_protection; - hasVirusProtection = domain.has_virus_protection; - } else { - ctx.logger.warn(ctx.translateError('DOMAIN_DOES_NOT_EXIST')); - /* - // TODO: - ctx.logger.error( - new Error( - `${ctx.query.domain} has forward-email-site-verification=${verifications[0]} which is no longer valid and needs fixed` - ) - ); - */ - } - } else if (ports.length > 0) { - if (ports.length > 1) - ctx.logger.error(ctx.translateError('MULTIPLE_PORT_RECORDS')); - port = ports[0]; - } - - if (!isPort(port)) { - ctx.logger.error(ctx.translateError('INVALID_PORT')); - port = '25'; - } - - ctx.body = { - port, - has_adult_content_protection: hasAdultContentProtection, - has_phishing_protection: hasPhishingProtection, - has_executable_protection: hasExecutableProtection, - has_virus_protection: hasVirusProtection - }; - } catch (err) { - // superagent inside of the smtp-server will retry on 408 error code - // therefore if it is a DNS error, then send that retry code - // otherwise send a bad request error with the error - if (err.code && HTTP_RETRY_ERROR_CODES.has(err.code)) - throw Boom.clientTimeout(err); - throw Boom.badRequest(err); - } - } catch (err) { - ctx.throw(err); - } -} - -module.exports = settings; diff --git a/app/controllers/api/v1/stripe.js b/app/controllers/api/v1/stripe.js deleted file mode 100644 index 7cc57504e..000000000 --- a/app/controllers/api/v1/stripe.js +++ /dev/null @@ -1,61 +0,0 @@ -const Boom = require('@hapi/boom'); -const Stripe = require('stripe'); -const isSANB = require('is-string-and-not-blank'); - -const env = require('#config/env'); - -const stripe = new Stripe(env.STRIPE_SECRET_KEY); - -// -async function webhook(ctx) { - try { - const sig = ctx.request.get('stripe-signature'); - - // throw an error if something was wrong - if (!isSANB(sig)) - throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE')); - - const event = stripe.webhooks.constructEvent( - ctx.request.rawBody, - sig, - env.STRIPE_ENDPOINT_SECRET - ); - - // throw an error if something was wrong - if (!event) - throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE')); - - // handle the event - // switch(event.type) - - // NOTE: for now we just manually email admins of every event - // (and manual edits can be made as needed) - - // return a response to acknowledge receipt of the event - ctx.body = { received: true }; - - ctx.logger.info('stripe webhook', { event }); - - // email admins here - /* - try { - await email({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Stripe Webhook: ${event.type}` - }, - locals: { - message: `
${JSON.stringify(event, null, 2)}
` - } - }); - } catch (err) { - ctx.logger.fatal(err); - } - */ - } catch (err) { - ctx.throw(err); - } -} - -module.exports = webhook; diff --git a/app/controllers/api/v1/test.js b/app/controllers/api/v1/test.js deleted file mode 100644 index c0e520b87..000000000 --- a/app/controllers/api/v1/test.js +++ /dev/null @@ -1,13 +0,0 @@ -const cryptoRandomString = require('crypto-random-string'); -const slug = require('speakingurl'); -const isSANB = require('is-string-and-not-blank'); - -const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; - -async function test(ctx) { - const label = isSANB(ctx.query.label) ? slug(ctx.query.label) : 'alias'; - const str = await cryptoRandomString.async({ length: 10, characters }); - ctx.body = `${label}+${str}@forwardemail.net`; -} - -module.exports = test; diff --git a/app/controllers/api/v1/users.js b/app/controllers/api/v1/users.js deleted file mode 100644 index cdb570ab1..000000000 --- a/app/controllers/api/v1/users.js +++ /dev/null @@ -1,60 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); - -const sendVerificationEmail = require('#helpers/send-verification-email'); -const config = require('#config'); -const { Users } = require('#models'); - -async function create(ctx) { - const { body } = ctx.request; - - if (!isSANB(body.password)) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_PASSWORD'))); - - // register the user - const query = { email: body.email, locale: ctx.locale }; - query[config.userFields.hasVerifiedEmail] = false; - query[config.userFields.hasSetPassword] = true; - query[config.userFields.pendingRecovery] = false; - query[config.lastLocaleField] = ctx.locale; - - ctx.state.user = await Users.register(query, body.password); - - // send a verification email if needed - if (!ctx.state.user[config.userFields.hasVerifiedEmail]) - ctx.state.user = await sendVerificationEmail(ctx); - - // send the response - const object = ctx.state.user.toObject(); - object[config.userFields.apiToken] = - ctx.state.user[config.userFields.apiToken]; - ctx.body = object; -} - -async function retrieve(ctx) { - // since we already have the user object - // just send it over as a response - ctx.body = ctx.state.user.toObject(); -} - -async function update(ctx) { - const { body } = ctx.request; - - if (_.isString(body.email)) ctx.state.user.email = body.email; - - if (_.isString(body[config.passport.fields.givenName])) - ctx.state.user[config.passport.fields.givenName] = - body[config.passport.fields.givenName]; - - if (_.isString(body[config.passport.fields.familyName])) - ctx.state.user[config.passport.fields.familyName] = - body[config.passport.fields.familyName]; - - if (_.isString(body.avatar_url)) ctx.state.user.avatar_url = body.avatar_url; - - ctx.state.user = await ctx.state.user.save(); - ctx.body = ctx.state.user.toObject(); -} - -module.exports = { create, retrieve, update }; diff --git a/app/controllers/web/admin/domains.js b/app/controllers/web/admin/domains.js deleted file mode 100644 index 8b9c0daa0..000000000 --- a/app/controllers/web/admin/domains.js +++ /dev/null @@ -1,96 +0,0 @@ -const Boom = require('@hapi/boom'); -const paginate = require('koa-ctx-paginate'); - -const { Domains } = require('#models'); - -async function list(ctx) { - const query = {}; - - // filter based on regex name - if (ctx.query.name) query.name = { $regex: ctx.query.name, $options: 'i' }; - - const [domains, itemCount] = await Promise.all([ - // eslint-disable-next-line unicorn/no-array-callback-reference - Domains.find(query) - .limit(ctx.query.limit) - .skip(ctx.paginate.skip) - .sort(ctx.query.sort || '-created_at') - .lean() - .exec(), - Domains.countDocuments(query) - ]); - - const pageCount = Math.ceil(itemCount / ctx.query.limit); - - if (ctx.accepts('html')) - return ctx.render('admin/domains', { - domains, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - const table = await ctx.render('admin/domains/_table', { - domains, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - ctx.body = { table }; -} - -async function update(ctx) { - const domain = await Domains.findById(ctx.params.id); - - if (!domain) - throw Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST_ANYWHERE')); - - const { body } = ctx.request; - - // save max recipients or if null keep the same - domain.max_recipients_per_alias = - body.max_recipients_per_alias || domain.max_recipients_per_alias; - - await domain.save(); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -async function remove(ctx) { - const domain = await Domains.findById(ctx.params.id); - - if (!domain) - throw Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST_ANYWHERE')); - - await domain.remove(); - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = { - list, - remove, - update -}; diff --git a/app/controllers/web/admin/index.js b/app/controllers/web/admin/index.js deleted file mode 100644 index 8a44ebe2f..000000000 --- a/app/controllers/web/admin/index.js +++ /dev/null @@ -1,4 +0,0 @@ -const users = require('./users'); -const domains = require('./domains'); - -module.exports = { users, domains }; diff --git a/app/controllers/web/admin/users.js b/app/controllers/web/admin/users.js deleted file mode 100644 index 7b2887b02..000000000 --- a/app/controllers/web/admin/users.js +++ /dev/null @@ -1,138 +0,0 @@ -const Boom = require('@hapi/boom'); -const paginate = require('koa-ctx-paginate'); -const { boolean } = require('boolean'); - -const { Users } = require('#models'); -const config = require('#config'); - -const USER_SEARCH_PATHS = [ - 'email', - config.passport.fields.givenName, - config.passport.fields.familyName -]; - -async function list(ctx) { - let query = {}; - - if (ctx.query.q) { - query = { $or: [] }; - - for (const field of USER_SEARCH_PATHS) { - query.$or.push({ [field]: { $regex: ctx.query.q, $options: 'i' } }); - } - } - - const [users, itemCount] = await Promise.all([ - // eslint-disable-next-line unicorn/no-array-callback-reference - Users.find(query) - .limit(ctx.query.limit) - .skip(ctx.paginate.skip) - .lean() - .sort(ctx.query.sort || '-created_at') - .exec(), - Users.countDocuments(query) - ]); - - const pageCount = Math.ceil(itemCount / ctx.query.limit); - - if (ctx.accepts('html')) - return ctx.render('admin/users', { - users, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - const table = await ctx.render('admin/users/_table', { - users, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - ctx.body = { table }; -} - -async function retrieve(ctx) { - ctx.state.result = await Users.findById(ctx.params.id); - if (!ctx.state.result) - throw Boom.notFound(ctx.translateError('INVALID_USER')); - return ctx.render('admin/users/retrieve'); -} - -async function update(ctx) { - const user = await Users.findById(ctx.params.id); - if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER')); - const { body } = ctx.request; - - user[config.passport.fields.givenName] = - body[config.passport.fields.givenName]; - user[config.passport.fields.familyName] = - body[config.passport.fields.familyName]; - user[config.passport.fields.otpEnabled] = - body[config.passport.fields.otpEnabled]; - user.email = body.email; - user.group = body.group; - - if (boolean(!body[config.passport.fields.otpEnabled])) - user[config.userFields.pendingRecovery] = false; - - await user.save(); - - if (user.id === ctx.state.user.id) await ctx.login(user); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -async function remove(ctx) { - const user = await Users.findById(ctx.params.id); - if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER')); - await user.remove(); - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -async function login(ctx) { - const user = await Users.findById(ctx.params.id); - if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER')); - - ctx.logout(); - - await ctx.login(user); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('/'); - else ctx.body = { redirectTo: '/' }; -} - -module.exports = { list, retrieve, update, remove, login }; diff --git a/app/controllers/web/my-account/cancel-email-change.js b/app/controllers/web/my-account/cancel-email-change.js deleted file mode 100644 index 7b69c244c..000000000 --- a/app/controllers/web/my-account/cancel-email-change.js +++ /dev/null @@ -1,32 +0,0 @@ -const Boom = require('@hapi/boom'); - -const config = require('#config'); - -async function cancelEmailChange(ctx) { - // if no email change request exists throw an error - if (!ctx.state.user[config.userFields.changeEmailToken]) - throw ctx.throw( - Boom.badRequest(ctx.translateError('EMAIL_CHANGE_DOES_NOT_EXIST')) - ); - - ctx.state.user[config.userFields.changeEmailToken] = null; - ctx.state.user[config.userFields.changeEmailNewAddress] = null; - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] = null; - ctx.state.user = await ctx.state.user.save(); - - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('EMAIL_CHANGE_CANCELLED'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = cancelEmailChange; diff --git a/app/controllers/web/my-account/cancel-subscription.js b/app/controllers/web/my-account/cancel-subscription.js deleted file mode 100644 index dbf739c44..000000000 --- a/app/controllers/web/my-account/cancel-subscription.js +++ /dev/null @@ -1,59 +0,0 @@ -const { promisify } = require('util'); - -const Boom = require('@hapi/boom'); -const Stripe = require('stripe'); -const isSANB = require('is-string-and-not-blank'); -const ms = require('ms'); -const paypal = require('paypal-rest-sdk'); -const superagent = require('superagent'); - -const env = require('#config/env'); -const config = require('#config'); - -const PAYPAL_ENDPOINT = - env.NODE_ENV === 'production' - ? 'https://api-m.paypal.com' - : 'https://api-m.sandbox.paypal.com'; - -const stripe = new Stripe(env.STRIPE_SECRET_KEY); - -async function cancelSubscription(ctx, next) { - if ( - !isSANB(ctx.state.user[config.userFields.stripeSubscriptionID]) && - !isSANB(ctx.state.user[config.userFields.paypalSubscriptionID]) - ) - throw Boom.badRequest(ctx.translateError('SUBSCRIPTION_ALREADY_CANCELLED')); - - await Promise.all([ - isSANB(ctx.state.user[config.userFields.stripeSubscriptionID]) - ? stripe.subscriptions.del( - ctx.state.user[config.userFields.stripeSubscriptionID] - ) - : Promise.resolve(), - isSANB(ctx.state.user[config.userFields.paypalSubscriptionID]) - ? (async () => { - const token = await promisify(paypal.generateToken)(); - await superagent - .post( - `${PAYPAL_ENDPOINT}/v1/billing/subscriptions/${ - ctx.state.user[config.userFields.paypalSubscriptionID] - }/cancel` - ) - .set('Content-Type', 'application/json') - .set('Authorization', token) - .timeout(ms('5s')); - })() - : Promise.resolve() - ]); - - ctx.state.user[config.userFields.stripeSubscriptionID] = null; - ctx.state.user[config.userFields.paypalSubscriptionID] = null; - - await ctx.state.user.save(); - - ctx.flash('success', ctx.translate('SUBSCRIPTION_CANCELLED')); - - return next(); -} - -module.exports = cancelSubscription; diff --git a/app/controllers/web/my-account/create-alias-form.js b/app/controllers/web/my-account/create-alias-form.js deleted file mode 100644 index 8badf9a2d..000000000 --- a/app/controllers/web/my-account/create-alias-form.js +++ /dev/null @@ -1,13 +0,0 @@ -function createAliasForm(ctx, next) { - ctx.state.breadcrumbHeaderCentered = true; - ctx.state.breadcrumbs = [ - 'my-account', - 'domains', - { - name: ctx.state.t('Add Alias') - } - ]; - return next(); -} - -module.exports = createAliasForm; diff --git a/app/controllers/web/my-account/create-alias.js b/app/controllers/web/my-account/create-alias.js deleted file mode 100644 index d53f07f38..000000000 --- a/app/controllers/web/my-account/create-alias.js +++ /dev/null @@ -1,65 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); -const { boolean } = require('boolean'); - -const toObject = require('#helpers/to-object'); -const { Users, Domains, Aliases } = require('#models'); - -async function createAlias(ctx, next) { - try { - if ( - isSANB(ctx.state.body.name) && - !ctx.state.body.name.startsWith('/') && - ctx.state.body.name.includes('+') - ) - return ctx.throw( - Boom.badRequest(ctx.translateError('ALIAS_WITH_PLUS_UNSUPPORTED')) - ); - - ctx.state.alias = await Aliases.create({ - ...ctx.state.body, - is_api: boolean(ctx.api), - user: ctx.state.user._id, - domain: ctx.state.domain._id, - locale: ctx.locale - }); - - if (ctx.api) { - ctx.state.alias = toObject(Aliases, ctx.state.alias); - ctx.state.alias.user = toObject(Users, ctx.state.user); - ctx.state.alias.domain = toObject(Domains, ctx.state.domain); - ctx.state.alias.domain.members = ctx.state.domain.members; - ctx.state.alias.domain.invites = ctx.state.domain.invites; - return next(); - } - - if ( - ctx.state.alias.has_recipient_verification && - (!ctx.state.domain.has_mx_record || !ctx.state.domain.has_txt_record) - ) - ctx.flash( - 'warning', - ctx.translate('RECIPIENT_VERIFICATION_PENDING_DOMAIN_VERIFICATION') - ); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - const redirectTo = ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/aliases` - ); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; - } catch (err) { - ctx.logger.error(err); - ctx.throw(Boom.badRequest(err.message)); - } -} - -module.exports = createAlias; diff --git a/app/controllers/web/my-account/create-domain-billing.js b/app/controllers/web/my-account/create-domain-billing.js deleted file mode 100644 index ab0bee45e..000000000 --- a/app/controllers/web/my-account/create-domain-billing.js +++ /dev/null @@ -1,321 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const checkoutNodeJssdk = require('@paypal/checkout-server-sdk'); -const dayjs = require('dayjs-with-plugins'); -const isSANB = require('is-string-and-not-blank'); -const ms = require('ms'); -const cryptoRandomString = require('crypto-random-string'); -const humanize = require('humanize-string'); -const titleize = require('titleize'); -const Stripe = require('stripe'); -const slug = require('speakingurl'); -const striptags = require('striptags'); - -const env = require('#config/env'); -const config = require('#config'); - -const payPalClient = new checkoutNodeJssdk.core.PayPalHttpClient( - config.payments.paypalCheckoutSdkConfig -); - -const stripe = new Stripe(env.STRIPE_SECRET_KEY); - -const { STRIPE_MAPPING, PAYPAL_MAPPING, PAYMENT_DURATIONS } = config.payments; - -// eslint-disable-next-line complexity -async function createDomainBilling(ctx) { - try { - // - // validate form body - // - let { - plan, - payment_method: paymentMethod, - payment_type: paymentType, - payment_duration: paymentDuration - } = ctx.request.body; - - const isMakePayment = - ctx.pathWithoutLocale === '/my-account/billing/make-payment'; - - if (isMakePayment && ctx.state.user.plan === 'free') - throw ctx.translateError('INVALID_PLAN'); - - if (isMakePayment) { - paymentType = 'one-time'; - plan = ctx.state.user.plan; - } - - // plan - if ( - !isSANB(plan) || - !['free', 'enhanced_protection', 'team'].includes(plan) - ) - throw ctx.translateError('INVALID_PLAN'); - - // payment_method - if ( - !isSANB(paymentMethod) || - !['credit_card', 'paypal'].includes(paymentMethod) - ) - throw ctx.translateError('INVALID_PAYMENT_METHOD'); - - // payment_type - if ( - !isSANB(paymentType) || - !['one-time', 'subscription'].includes(paymentType) - ) - throw ctx.translateError('INVALID_PAYMENT_TYPE'); - - // payment_duration - if (!isSANB(paymentDuration) || !PAYMENT_DURATIONS.has(paymentDuration)) - throw ctx.translateError('INVALID_PAYMENT_DURATION'); - - // don't allow a user to have a subscription paymentType selected - // with 2y, 3y, 4y, 5y, or lifetime selected (in other words if the mapping doesn't exist) - let price; - if (paymentMethod === 'credit_card') - price = STRIPE_MAPPING[plan][paymentType][paymentDuration]; - else if (paymentMethod === 'paypal') - price = PAYPAL_MAPPING[plan][paymentDuration]; - - if (!isSANB(price) && !_.isFinite(price)) - throw ctx.translateError('INVALID_PAYMENT_DURATION'); - - // One-time payment for 3 months of Team plan - // Subscription payment for 1 year of Enhanced Protection plan - const duration = dayjs() - .add(ms(paymentDuration), 'millisecond') - .fromNow(true); - const description = striptags( - ctx.translate( - 'PAYMENT_DESCRIPTION', - _.capitalize(paymentType), - duration, - titleize(humanize(plan)) - ) - ); - - let reference = await cryptoRandomString.async(config.referenceOptions); - reference = reference.toUpperCase(); - - // - // stripe - // - if (paymentMethod === 'credit_card') { - // if the user didn't have JavaScript enabled, then redirect them to Stripe page - if (ctx.accepts('html')) throw ctx.translateError('JAVASCRIPT_REQUIRED'); - - const options = { - // TODO: add alipay and others - payment_method_types: ['card'], - mode: paymentType === 'one-time' ? 'payment' : 'subscription', - ...(isSANB(ctx.state.user[config.userFields.stripeCustomerID]) - ? { customer: ctx.state.user[config.userFields.stripeCustomerID] } - : { customer_email: ctx.state.user.email }), - client_reference_id: reference, - line_items: [ - { - price, - quantity: 1, - description - } - ], - cancel_url: `${config.urls.web}${ctx.path}${ - isMakePayment ? '' : `/?plan=${plan}` - }`, - success_url: `${config.urls.web}${ctx.path}/?${ - isMakePayment ? '' : `plan=${plan}&` - }session_id={CHECKOUT_SESSION_ID}` - }; - - ctx.logger.info('stripe.checkout.sessions.create', { options }); - - const session = await stripe.checkout.sessions.create(options); - - ctx.logger.info('stripe.checkout.sessions.create', { session }); - ctx.body = { sessionId: session.id }; - return; - } - - if (paymentMethod === 'paypal') { - // paypal - const name = ctx.translate(plan.toUpperCase()); - - // one-time - // - if (paymentType === 'one-time') { - // - const request = new checkoutNodeJssdk.orders.OrdersCreateRequest(); - // the SKU should look like a product SKU - // (e.g. "ENHANCED-PROTECTION" vs. "ENHANCED_PROTECTION") - const sku = slug(plan).toUpperCase(); - // prepare the request body - const requestBody = { - intent: 'CAPTURE', - application_context: { - cancel_url: `${config.urls.web}${ctx.path}${ - isMakePayment ? '' : `/?plan=${plan}` - }`, - return_url: `${config.urls.web}${ctx.path}/?plan=${plan}`, - brand_name: 'Forward Email', - shipping_preference: 'NO_SHIPPING', - user_action: 'PAY_NOW' - }, - payer: { - email_address: ctx.state.user.email - }, - purchase_units: [ - { - reference_id: reference, - description, - custom_id: sku, - soft_descriptor: sku, - amount: { - currency_code: 'USD', - value: price, - breakdown: { - item_total: { - currency_code: 'USD', - value: price - } - } - }, - items: [ - { - name, - description, - sku, - unit_amount: { - currency_code: 'USD', - value: price - }, - quantity: 1, - category: 'DIGITAL_GOODS' // NOTE: missing "SERVICE" here - } - ] - } - ] - }; - - // NOTE: what is this nonsense - request.prefer('return=representation'); - - // NOTE: this API is so odd - request.requestBody(requestBody); - - ctx.logger.info( - 'request.requestBody', - JSON.stringify(requestBody, null, 2) - ); - - try { - const order = await payPalClient.execute(request); - ctx.logger.info('payPalClient.execute', { order }); - ctx.body = { orderID: order.result.id }; - } catch (err) { - ctx.logger.error(err); - try { - // PayPal returns a stringifed JavaScript object in the response body - // (so we should try to parse it using JSON.parse and then pull out the `message` property) - // err.message = {"name":"INVALID_REQUEST","message":"Request is not well-formed, syntactically incorrect, or violates schema.","debug_id":"1d5686dcdfa41","details":[{"field":"/purchase_units/@reference_id=='WyA2Pn'/amount/value","value":"","location":"body","issue":"MISSING_REQUIRED_PARAMETER","description":"A required field / parameter is missing."},{"field":"/purchase_units/@reference_id=='WyA2Pn'/items/0/unit_amount/value","value":"","location":"body","issue":"MISSING_REQUIRED_PARAMETER","description":"A required field / parameter is missing."}],"links":[{"href":"https://developer.paypal.com/docs/api/orders/v2/#error-MISSING_REQUIRED_PARAMETER","rel":"information_link","encType":"application/json"}]} - err.original_message = err.message; - err.message = JSON.parse(err.message).message; - } catch (err) { - // if it wasn't a PayPal error - ctx.logger.debug(err); - } - - throw err; - } - - return; - } - - // - // subscriptions (handled strictly on the client-side) - // - if (paymentType === 'subscription') { - throw ctx.translateError('UNKNOWN_ERROR'); - /* - // - const billingAgreement = await new Promise((resolve, reject) => { - paypal.billingAgreement.create( - { - name, - description, - intent: 'SUBSCRIPTION', - plan_id: 'P-94C73110FP343152JL6S4QHQ', - // start time is current time + 1 minute - start_time: new Date(Date.now() + 1000 * 60).toISOString(), - subscriber: { - name: { - given_name: 'Foo', - surname: 'Bar' - }, - email_address: 'foo@example.com' - }, - payer: { - email_address: ctx.state.user.email - }, - application_context: { - brand_name: 'Forward Email', - shipping_preference: 'NO_SHIPPING', - user_action: 'SUBSCRIBE_NOW', - payment_method: { - payer_selected: 'PAYPAL', - payee_preferred: 'IMMEDIATE_PAYMENT_REQUIRED' - }, - cancel_url: 'https://forwardemail.net/cancel', - return_url: 'https://forwardemail.net/return' - } - }, - // {}, - (err, data) => { - console.log( - JSON.stringify( - { - err, - data - }, - null, - 2 - ) - ); - if (err) return reject(err); - resolve(data); - } - ); - }); - console.log('billingAgreement', billingAgreement); - for (const link of billingAgreement.links) { - if (link.rel === 'approval_url') { - console.log('link', link); - } - } - - throw new Error('test'); - */ - } - - return; - } - - throw ctx.translateError('UNKNOWN_ERROR'); - } catch (err) { - ctx.logger.error(err); - - if (ctx.accepts('html')) { - ctx.flash('error', err.message); - return ctx.redirect( - ctx.state.domain - ? ctx.state.l(`/my-account/domains/${ctx.state.domain.name}`) - : ctx.state.l('/my-account/billing') - ); - } - - ctx.throw(Boom.badRequest(err.message)); - } -} - -module.exports = createDomainBilling; diff --git a/app/controllers/web/my-account/create-domain-form.js b/app/controllers/web/my-account/create-domain-form.js deleted file mode 100644 index 425109184..000000000 --- a/app/controllers/web/my-account/create-domain-form.js +++ /dev/null @@ -1,17 +0,0 @@ -function createDomainForm(ctx, next) { - ctx.state.breadcrumbHeaderCentered = true; - ctx.state.breadcrumbs = [ - 'my-account', - { - name: ctx.state.t('Domains'), - header: ctx.state.t('Add Domain'), - href: ctx.state.l('/my-account/domains') - }, - { - name: ctx.state.t('Add Domain') - } - ]; - return next(); -} - -module.exports = createDomainForm; diff --git a/app/controllers/web/my-account/create-domain.js b/app/controllers/web/my-account/create-domain.js deleted file mode 100644 index 1ec8e6b99..000000000 --- a/app/controllers/web/my-account/create-domain.js +++ /dev/null @@ -1,69 +0,0 @@ -const Boom = require('@hapi/boom'); -const { boolean } = require('boolean'); - -const toObject = require('#helpers/to-object'); -const { Users, Domains, Aliases } = require('#models'); - -async function createDomain(ctx, next) { - try { - ctx.state.domain = await Domains.create({ - is_api: boolean(ctx.api), - members: [{ user: ctx.state.user._id, group: 'admin' }], - name: ctx.request.body.domain, - is_global: - ctx.state.user.group === 'admin' && boolean(ctx.request.body.is_global), - locale: ctx.locale, - plan: ctx.request.body.plan, - client: ctx.client, - ...ctx.state.optionalBooleans - }); - - // create a default alias for the user pointing to the admin - if (ctx.state.recipients.length > 0) - await Aliases.create({ - is_api: boolean(ctx.api), - user: ctx.state.user._id, - domain: ctx.state.domain._id, - name: '*', - recipients: ctx.state.recipients, - locale: ctx.locale, - ...(ctx.state.optionalBooleans.has_recipient_verification - ? { has_recipient_verification: true } - : {}) - }); - - if (ctx.api) { - ctx.state.domain = toObject(Domains, ctx.state.domain); - ctx.state.domain.members[0].user = toObject(Users, ctx.state.user); - return next(); - } - - // TODO: flash messages logic in @ladjs/assets doesn't support both - // custom and regular flash message yet - if (ctx.state.domain.name.startsWith('www.') && !ctx.api) { - ctx.flash( - 'error', - ctx - .translate('WWW_WARNING') - .replace(/example.com/g, ctx.state.domain.name.replace('www.', '')) - ); - } else if (!ctx.api) { - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - } - - if (ctx.accepts('html')) ctx.redirect(ctx.state.redirectTo); - else ctx.body = { redirectTo: ctx.state.redirectTo }; - } catch (err) { - ctx.throw(Boom.badRequest(err)); - } -} - -module.exports = createDomain; diff --git a/app/controllers/web/my-account/create-invite.js b/app/controllers/web/my-account/create-invite.js deleted file mode 100644 index 1cdc9ebba..000000000 --- a/app/controllers/web/my-account/create-invite.js +++ /dev/null @@ -1,91 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); -const { isEmail } = require('validator'); - -const emailHelper = require('#helpers/email'); -const { Users, Domains } = require('#models'); - -async function createInvite(ctx, next) { - // ctx.request.body.email - // ctx.query.email - const email = ctx.request.body.email || ctx.query.email; - if (!isSANB(email) || !isEmail(email)) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_EMAIL'))); - - // ctx.request.body.group - if ( - !isSANB(ctx.request.body.group) || - !['admin', 'user'].includes(ctx.request.body.group) - ) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_GROUP'))); - - // ensure invite does not already exist - const invite = ctx.state.domain.invites.find( - (invite) => invite.email.toLowerCase() === email.toLowerCase() - ); - - if (invite) - return ctx.throw( - Boom.badRequest(ctx.translateError('INVITE_ALREADY_SENT')) - ); - - // ensure user is not already a member - const user = await Users.findOne({ email }).lean().exec(); - if (user) { - const member = ctx.state.domain.members.find( - (member) => member.user.id === user.id - ); - if (member) - return ctx.throw( - Boom.badRequest(ctx.translateError('USER_ALREADY_MEMBER')) - ); - } - - // create the invite - ctx.state.domain = await Domains.findById(ctx.state.domain._id); - if (!ctx.state.domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - ctx.state.domain.invites.push({ - email: email.toLowerCase(), - group: ctx.request.body.group - }); - ctx.state.domain.locale = ctx.locale; - ctx.state.domain.skip_verification = true; - ctx.state.domain = await ctx.state.domain.save(); - - // send an email - try { - await emailHelper({ - template: 'invite', - message: { - to: email.toLowerCase() - }, - locals: { - domain: { id: ctx.state.domain.id, name: ctx.state.domain.name } - } - }); - } catch (err) { - if (!ctx.api) ctx.flash('error', ctx.translate('INVITE_EMAIL_ERROR')); - ctx.logger.error(err); - } - - if (ctx.api) return next(); - - // send response - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = createInvite; diff --git a/app/controllers/web/my-account/ensure-alias-admin.js b/app/controllers/web/my-account/ensure-alias-admin.js deleted file mode 100644 index 07d3f0140..000000000 --- a/app/controllers/web/my-account/ensure-alias-admin.js +++ /dev/null @@ -1,8 +0,0 @@ -const Boom = require('@hapi/boom'); - -function ensureAliasAdmin(ctx, next) { - if (ctx.state.alias.group === 'admin') return next(); - ctx.throw(Boom.badRequest(ctx.translateError('IS_NOT_ADMIN'))); -} - -module.exports = ensureAliasAdmin; diff --git a/app/controllers/web/my-account/ensure-domain-admin.js b/app/controllers/web/my-account/ensure-domain-admin.js deleted file mode 100644 index 5ddf39bef..000000000 --- a/app/controllers/web/my-account/ensure-domain-admin.js +++ /dev/null @@ -1,8 +0,0 @@ -const Boom = require('@hapi/boom'); - -function ensureDomainAdmin(ctx, next) { - if (ctx.state.domain.group === 'admin') return next(); - ctx.throw(Boom.badRequest(ctx.translateError('IS_NOT_ADMIN'))); -} - -module.exports = ensureDomainAdmin; diff --git a/app/controllers/web/my-account/ensure-not-banned.js b/app/controllers/web/my-account/ensure-not-banned.js deleted file mode 100644 index da3697d76..000000000 --- a/app/controllers/web/my-account/ensure-not-banned.js +++ /dev/null @@ -1,11 +0,0 @@ -const Boom = require('@hapi/boom'); - -const config = require('#config'); - -function ensureNotBanned(ctx, next) { - if (ctx.state.user[config.userFields.isBanned]) - return ctx.throw(Boom.forbidden(ctx.translateError('ACCOUNT_BANNED'))); - return next(); -} - -module.exports = ensureNotBanned; diff --git a/app/controllers/web/my-account/ensure-team-plan.js b/app/controllers/web/my-account/ensure-team-plan.js deleted file mode 100644 index 753256b0c..000000000 --- a/app/controllers/web/my-account/ensure-team-plan.js +++ /dev/null @@ -1,6 +0,0 @@ -function ensureTeamPlan(ctx, next) { - ctx.state.isTeamPlanRequired = ctx.state.domain.plan !== 'team'; - return next(); -} - -module.exports = ensureTeamPlan; diff --git a/app/controllers/web/my-account/ensure-upgraded-plan.js b/app/controllers/web/my-account/ensure-upgraded-plan.js deleted file mode 100644 index af4608162..000000000 --- a/app/controllers/web/my-account/ensure-upgraded-plan.js +++ /dev/null @@ -1,32 +0,0 @@ -const Boom = require('@hapi/boom'); - -function ensureUpgradedPlan(ctx, next) { - if (ctx.state.domain.plan !== 'free' && !ctx.state.isTeamPlanRequired) - return next(); - - const redirectTo = ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/billing?plan=enhanced_protection` - ); - - const swal = { - title: ctx.translate('UPGRADE_PLAN'), - html: ctx.translate('PLAN_UPGRADE_REQUIRED', redirectTo), - type: 'warning' - }; - - if (ctx.api) - return ctx.throw( - Boom.badRequest(ctx.translateError('PLAN_UPGRADE_REQUIRED', redirectTo)) - ); - - if (ctx.method === 'GET' || ctx.accepts('html')) { - if (!ctx.api) ctx.flash('custom', swal); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; - return; - } - - ctx.body = { swal }; -} - -module.exports = ensureUpgradedPlan; diff --git a/app/controllers/web/my-account/import-aliases.js b/app/controllers/web/my-account/import-aliases.js deleted file mode 100644 index 054c3b905..000000000 --- a/app/controllers/web/my-account/import-aliases.js +++ /dev/null @@ -1,197 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const isFQDN = require('is-fqdn'); -const { boolean } = require('boolean'); -const { isEmail, isIP } = require('validator'); - -const { Domains, Aliases } = require('#models'); - -// eslint-disable-next-line complexity -async function importAliases(ctx) { - if (ctx.state.domain.is_global) - return ctx.throw(Boom.badRequest(ctx.translateError('IS_NOT_ADMIN'))); - - let forwardingAddresses; - let globalForwardingAddresses; - let ignoredAddresses; - let errors; - try { - ({ - forwardingAddresses, - globalForwardingAddresses, - ignoredAddresses, - errors - } = await Domains.getTxtAddresses( - ctx.state.domain.name, - ctx.locale, - false, - ctx.client - )); - } catch (err) { - ctx.logger.error(err); - if (err.code === 'ENOTFOUND') - throw Boom.badRequest(ctx.translateError('ENOTFOUND')); - if (err.code === 'ENODATA') - throw Boom.badRequest(ctx.translateError('MISSING_DNS_TXT')); - throw err; - } - - // - // NOTE: eventually rewrite this, it was a quick hack - // - const aliases = []; - const catchAll = []; - - for (const element of ignoredAddresses) { - const match = aliases.find((alias) => alias.name === element.name); - const existing = ctx.state.domain.aliases.find( - (alias) => alias.name === element.name - ); - if (existing) - errors.push( - ctx.translate( - 'IMPORT_ALIAS_ALREADY_EXISTS', - element.name, - element.recipient - ) - ); - else if (match) { - if (element.recipient) match.recipients.push(element.recipient); - else - errors.push( - ctx.translate('IMPORT_ALIAS_DISABLED_NOBODY', element.name) - ); - } else { - aliases.push({ - is_enabled: false, - user: ctx.state.user._id, - domain: ctx.state.domain._id, - name: element.name, - recipients: [element.recipient] - }); - } - } - - for (const element of forwardingAddresses) { - const match = aliases.find((alias) => alias.name === element.name); - const existing = ctx.state.domain.aliases.find( - (alias) => alias.name === element.name - ); - if (existing) - errors.push( - ctx.translate( - 'IMPORT_ALIAS_ALREADY_EXISTS', - element.name, - element.recipient - ) - ); - else if (match) match.recipients.push(element.recipient); - else - aliases.push({ - user: ctx.state.user._id, - domain: ctx.state.domain._id, - name: element.name, - recipients: [element.recipient] - }); - } - - for (const element of globalForwardingAddresses) { - // if it was a fqdn, ip, or email address then add global alias - // otherwise throw an error that it was an invalid global - if (isFQDN(element) || isIP(element) || isEmail(element)) { - const match = aliases.find((alias) => alias.name === '*'); - const existing = ctx.state.domain.aliases.find( - (alias) => alias.name === '*' - ); - // try to add to existing catch-all record if it wasn't already there - if (existing) { - if (existing.recipients.includes(element)) - errors.push( - ctx.translate('IMPORT_CATCHALL_ALREADY_INCLUDES', element) - ); - else catchAll.push(element); - } else if (match) match.recipients.push(element); - else - aliases.push({ - user: ctx.state.user._id, - domain: ctx.state.domain._id, - name: '*', - recipients: [element] - }); - } else { - ctx.logger.error( - new Error(`Invalid global forwarding address of ${element}`), - { domain: ctx.state.domain } - ); - } - } - - const messages = []; - - if (aliases.length > 0) - try { - const array = await Aliases.create( - aliases.map((alias) => ({ - ...alias, - is_api: boolean(ctx.api), - locale: ctx.locale - })) - ); - messages.push(ctx.translate('IMPORT_SUCCESSFUL', array.length)); - } catch (err) { - messages.push(ctx.translate('IMPORT_ERROR')); - ctx.logger.error(err); - errors.push(err); - } - else messages.push(ctx.translate('IMPORT_NO_ALIASES_AVAILABLE')); - - if (catchAll.length > 0) - try { - const alias = await Aliases.findOne({ - user: ctx.state.user._id, - domain: ctx.state.domain._id, - name: '*' - }); - for (const recipient of catchAll) { - alias.recipients.push(recipient); - } - - alias.locale = ctx.locale; - await alias.save(); - messages.push( - ctx.translate('IMPORT_CATCHALL_SUCCESSFUL', catchAll.length) - ); - } catch (err) { - messages.push(ctx.translate('IMPORT_CATCHALL_ERROR')); - ctx.logger.error(err); - errors.push(err); - } - else messages.push(ctx.translate('IMPORT_CATCHALL_NONE')); - - errors = _.uniqBy(errors, 'message'); - - const message = - errors.length > 0 - ? `

${messages.join( - ' ' - )}

The following errors occurred:

  • ${errors - .map((err) => err.message) - .join('
  • ')}
` - : messages.join(' '); - - const redirectTo = ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/aliases` - ); - - if (ctx.accepts('html')) { - if (!ctx.api) ctx.flash('info', message); - ctx.redirect(redirectTo); - } else { - ctx.body = { - message, - redirectTo - }; - } -} - -module.exports = importAliases; diff --git a/app/controllers/web/my-account/index.js b/app/controllers/web/my-account/index.js deleted file mode 100644 index f95b31166..000000000 --- a/app/controllers/web/my-account/index.js +++ /dev/null @@ -1,89 +0,0 @@ -const cancelEmailChange = require('./cancel-email-change'); -const cancelSubscription = require('./cancel-subscription'); -const createAlias = require('./create-alias'); -const createAliasForm = require('./create-alias-form'); -const createDomain = require('./create-domain'); -const createDomainForm = require('./create-domain-form'); -const createDomainBilling = require('./create-domain-billing'); -const createInvite = require('./create-invite'); -const ensureAliasAdmin = require('./ensure-alias-admin'); -const ensureDomainAdmin = require('./ensure-domain-admin'); -const ensureNotBanned = require('./ensure-not-banned'); -const ensureTeamPlan = require('./ensure-team-plan'); -const ensureUpgradedPlan = require('./ensure-upgraded-plan'); -const importAliases = require('./import-aliases'); -const listAliases = require('./list-aliases'); -const listBilling = require('./list-billing'); -const listDomains = require('./list-domains'); -const manageBilling = require('./manage-billing'); -const recoveryKeys = require('./recovery-keys'); -const remove = require('./remove'); -const removeAlias = require('./remove-alias'); -const removeDomain = require('./remove-domain'); -const removeInvite = require('./remove-invite'); -const removeMember = require('./remove-member'); -const resendEmailChange = require('./resend-email-change'); -const resetAPIToken = require('./reset-api-token'); -const retrieveAlias = require('./retrieve-alias'); -const retrieveAliases = require('./retrieve-aliases'); -const retrieveBilling = require('./retrieve-billing'); -const retrieveDomain = require('./retrieve-domain'); -const retrieveDomainBilling = require('./retrieve-domain-billing'); -const retrieveDomains = require('./retrieve-domains'); -const retrieveInvite = require('./retrieve-invite'); -const retrieveProfile = require('./retrieve-profile'); -const retrieveReceipt = require('./retrieve-receipt'); -const sortedDomains = require('./sorted-domains'); -const updateAlias = require('./update-alias'); -const updateDomain = require('./update-domain'); -const updateMember = require('./update-member'); -const updateProfile = require('./update-profile'); -const validateAlias = require('./validate-alias'); -const validateDomain = require('./validate-domain'); -const verifyRecords = require('./verify-records'); - -module.exports = { - cancelEmailChange, - cancelSubscription, - createAlias, - createAliasForm, - createDomain, - createDomainForm, - createDomainBilling, - createInvite, - ensureAliasAdmin, - ensureDomainAdmin, - ensureNotBanned, - ensureTeamPlan, - ensureUpgradedPlan, - importAliases, - listAliases, - listBilling, - listDomains, - manageBilling, - recoveryKeys, - remove, - removeAlias, - removeDomain, - removeInvite, - removeMember, - resendEmailChange, - resetAPIToken, - retrieveAlias, - retrieveAliases, - retrieveBilling, - retrieveDomain, - retrieveDomainBilling, - retrieveDomains, - retrieveInvite, - retrieveProfile, - retrieveReceipt, - sortedDomains, - updateAlias, - updateDomain, - updateMember, - updateProfile, - validateAlias, - validateDomain, - verifyRecords -}; diff --git a/app/controllers/web/my-account/list-aliases.js b/app/controllers/web/my-account/list-aliases.js deleted file mode 100644 index 6897c3325..000000000 --- a/app/controllers/web/my-account/list-aliases.js +++ /dev/null @@ -1,57 +0,0 @@ -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); -const paginate = require('koa-ctx-paginate'); -const RE2 = require('re2'); - -async function listAliases(ctx) { - const { domain } = ctx.state; - - // filter based on regex keyword - if (ctx.query.q) { - const qRegex = new RE2(_.escapeRegExp(ctx.query.q), 'gi'); - domain.aliases = domain.aliases.filter( - (alias) => - qRegex.test(`${alias.name}@${domain.name}`) || - qRegex.test(alias.description) || - alias.recipients.some((recipient) => qRegex.test(recipient)) - ); - } - - const itemCount = domain.aliases.length; - - const pageCount = Math.ceil(itemCount / ctx.query.limit); - - let sortFn; - if (isSANB(ctx.query.sort)) - sortFn = (a) => a[ctx.query.sort.replace(/^-/, '')]; - - domain.aliases = _.sortBy(domain.aliases, sortFn ? [sortFn] : ['name']); - - if (!sortFn || (isSANB(ctx.query.sort) && ctx.query.sort.startsWith('-'))) - domain.aliases = _.reverse(domain.aliases); - - // slice for page - domain.aliases = domain.aliases.slice( - ctx.paginate.skip, - ctx.query.limit + ctx.paginate.skip - ); - - if (ctx.accepts('html')) - return ctx.render('my-account/domains/aliases', { - domain, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - const table = await ctx.render('my-account/domains/aliases/_table', { - domain, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - ctx.body = { table }; -} - -module.exports = listAliases; diff --git a/app/controllers/web/my-account/list-billing.js b/app/controllers/web/my-account/list-billing.js deleted file mode 100644 index 1096e78ae..000000000 --- a/app/controllers/web/my-account/list-billing.js +++ /dev/null @@ -1,122 +0,0 @@ -const RE2 = require('re2'); -const _ = require('lodash'); -// const dayjs = require('dayjs-with-plugins'); -const isSANB = require('is-string-and-not-blank'); -const paginate = require('koa-ctx-paginate'); - -const REGEX_AMOUNT_FORMATTED = new RE2('amount_formatted', 'gi'); - -async function listBilling(ctx) { - let { payments } = ctx.state; - - // NOTE: I'm commenting this out for now, please see the GH issue below: - // - /* - // make sure dates are good - // and if not remove them from query - if (ctx.query.start_date) { - ctx.query.start_date = dayjs(ctx.query.start_date, 'YYYY-MM-DD'); - if (!ctx.query.start_date.isValid()) { - ctx.query.start_date = undefined; - delete ctx.query.start_date; - } - } - - if (ctx.query.end_date) { - ctx.query.end_date = dayjs(ctx.query.end_date, 'YYYY-MM-DD'); - if (!ctx.query.end_date.isValid()) { - ctx.query.end_date = undefined; - delete ctx.query.end_date; - } - } - - // filter based on regex keyword and/or dates - if (ctx.query.keyword || ctx.query.start_date || ctx.query.end_date) { - payments = payments.filter((payment) => - Object.entries(payment).some((property) => { - const key = property[0]; - const prop = property[1]; - - let isKeyword = false; - let isDate = false; - - // check keyword - if (ctx.query.keyword) { - isKeyword = - typeof prop === 'string' - ? new RE2(_.escapeRegExp(ctx.query.keyword), 'gi').test(prop) - : false; - } - - // check dates - if (key === 'invoice_at') { - if (ctx.query.start_date && ctx.query.end_date) { - isDate = dayjs(prop).isBetween( - ctx.query.start_date, - ctx.query.end_date, - 'day' - ); - } else if (ctx.query.start_date && !ctx.query.end_date) { - isDate = dayjs(prop).isSameOrAfter(ctx.query.start_date, 'day'); - } else if (!ctx.query.start_date && ctx.query.end_date) { - isDate = dayjs(prop).isSameOrBefore(ctx.query.end_date, 'day'); - } - } - - if (isKeyword || isDate) return true; - - return false; - }) - ); - } - */ - - const itemCount = payments.length; - - const pageCount = Math.ceil(itemCount / ctx.query.limit); - - // sort payments - let sortFn; - if (REGEX_AMOUNT_FORMATTED.test(ctx.query.sort)) - sortFn = (p) => p.amount_formatted.replace(/[^\d.]/, ''); - else if (isSANB(ctx.query.sort)) - sortFn = (p) => p[ctx.query.sort.replace(/^-/, '')]; - - payments = _.sortBy(payments, sortFn ? [sortFn] : ['invoice_at']); - - if (!sortFn || (isSANB(ctx.query.sort) && ctx.query.sort.startsWith('-'))) - payments = _.reverse(payments); - - // slice for page - payments = payments.slice( - ctx.paginate.skip, - ctx.query.limit + ctx.paginate.skip - ); - - ctx.state.breadcrumbHeaderCentered = true; - - if (ctx.accepts('html')) - return ctx.render('my-account/billing', { - payments, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - // this will assign rendered html to ctx.body - await ctx.render('my-account/billing/_table', { - payments, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - const table = - itemCount === 0 - ? '
No payments exist for that keyword or timeframe.
' - : ctx.body; - - ctx.body = { table }; -} - -module.exports = listBilling; diff --git a/app/controllers/web/my-account/list-domains.js b/app/controllers/web/my-account/list-domains.js deleted file mode 100644 index 9be810621..000000000 --- a/app/controllers/web/my-account/list-domains.js +++ /dev/null @@ -1,83 +0,0 @@ -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); -const paginate = require('koa-ctx-paginate'); -const RE2 = require('re2'); - -async function listDomains(ctx) { - let { domains } = ctx.state; - - // hide global domain names if not admin of - // the global domain and had zero aliases - domains = domains.filter((domain) => { - if (!domain.is_global) return true; - if ( - domain.members.some( - (member) => - // if the logged in user was not member - // and if the logged in user had no aliases - member.user.id === ctx.state.user.id && - member.is_virtual && - member.alias_count === 0 - ) - ) - return false; - return true; - }); - - // filter based on regex keyword - if (ctx.query.q) { - const qRegex = new RE2(_.escapeRegExp(ctx.query.q), 'gi'); - domains = domains.filter( - (domain) => - qRegex.test(domain.name) || - domain.aliases.some( - (alias) => - qRegex.test(alias.name) || - qRegex.test(alias.description) || - alias.recipients.some((recipient) => qRegex.test(recipient)) - ) - ); - } - - const itemCount = domains.length; - - const pageCount = Math.ceil(itemCount / ctx.query.limit); - - // sort domains - let sortFn; - if (new RE2('aliases', 'gi').test(ctx.query.sort)) - sortFn = (d) => d.aliases.length; - else if (isSANB(ctx.query.sort)) - sortFn = (d) => d[ctx.query.sort.replace(/^-/, '')]; - - // domains are already pre-sorted A-Z by 'name' so only use sortFn if passed - if (sortFn) domains = _.sortBy(domains, [sortFn]); - - if (isSANB(ctx.query.sort) && ctx.query.sort.startsWith('-')) - domains = _.reverse(domains); - - // slice for page - domains = domains.slice( - ctx.paginate.skip, - ctx.query.limit + ctx.paginate.skip - ); - - if (ctx.accepts('html')) - return ctx.render('my-account/domains', { - domains, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - const table = await ctx.render('my-account/domains/_table', { - domains, - pageCount, - itemCount, - pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) - }); - - ctx.body = { table }; -} - -module.exports = listDomains; diff --git a/app/controllers/web/my-account/manage-billing.js b/app/controllers/web/my-account/manage-billing.js deleted file mode 100644 index d520075de..000000000 --- a/app/controllers/web/my-account/manage-billing.js +++ /dev/null @@ -1,50 +0,0 @@ -const isSANB = require('is-string-and-not-blank'); -const Boom = require('@hapi/boom'); -const Stripe = require('stripe'); -const emailHelper = require('#helpers/email'); - -const env = require('#config/env'); -const config = require('#config'); - -const stripe = new Stripe(env.STRIPE_SECRET_KEY); - -async function redirectToPortal(ctx) { - try { - if (!isSANB(ctx.state.user[config.userFields.stripeSubscriptionID])) - throw Boom.badRequest(ctx.translateError('MISSING_PORTAL_CREDENTIALS')); - - if (!isSANB(ctx.state.user[config.userFields.stripeCustomerID])) - throw Boom.badRequest(ctx.translateError('MISSING_PORTAL_CREDENTIALS')); - - const session = await stripe.billingPortal.sessions.create({ - customer: ctx.state.user[config.userFields.stripeCustomerID], - return_url: `${config.urls.web}${ctx.state.l('/my-account/billing')}`, - locale: ctx.locale - }); - - if (!session) throw ctx.translateError('UNKNOWN_ERROR'); - - ctx.redirect(session.url); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error creating Stripe billing portal session for customer ID ${ - ctx.state.user[config.userFields.stripeCustomerID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - - throw ctx.translateError('UNKNOWN_ERROR'); - } -} - -module.exports = redirectToPortal; diff --git a/app/controllers/web/my-account/recovery-keys.js b/app/controllers/web/my-account/recovery-keys.js deleted file mode 100644 index 593179c15..000000000 --- a/app/controllers/web/my-account/recovery-keys.js +++ /dev/null @@ -1,10 +0,0 @@ -const config = require('#config'); - -async function recoveryKeys(ctx) { - const otpRecoveryKeys = ctx.state.user[config.userFields.otpRecoveryKeys]; - - ctx.attachment('recovery-keys.txt'); - ctx.body = otpRecoveryKeys.toString().replace(/,/g, '\n').replace(/"/g, ''); -} - -module.exports = recoveryKeys; diff --git a/app/controllers/web/my-account/remove-alias.js b/app/controllers/web/my-account/remove-alias.js deleted file mode 100644 index 6dd950ad9..000000000 --- a/app/controllers/web/my-account/remove-alias.js +++ /dev/null @@ -1,23 +0,0 @@ -const { Aliases } = require('#models'); - -async function removeAlias(ctx, next) { - await Aliases.findByIdAndRemove(ctx.state.alias._id); - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - if (ctx.api) return next(); - const redirectTo = ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/aliases` - ); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; -} - -module.exports = removeAlias; diff --git a/app/controllers/web/my-account/remove-domain.js b/app/controllers/web/my-account/remove-domain.js deleted file mode 100644 index a004438cd..000000000 --- a/app/controllers/web/my-account/remove-domain.js +++ /dev/null @@ -1,21 +0,0 @@ -const { Domains } = require('#models'); - -async function removeDomain(ctx, next) { - await Domains.findByIdAndRemove(ctx.state.domain._id); - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - if (ctx.api) return next(); - const redirectTo = ctx.state.l('/my-account/domains'); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; -} - -module.exports = removeDomain; diff --git a/app/controllers/web/my-account/remove-invite.js b/app/controllers/web/my-account/remove-invite.js deleted file mode 100644 index fd21009eb..000000000 --- a/app/controllers/web/my-account/remove-invite.js +++ /dev/null @@ -1,42 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); -const { isEmail } = require('validator'); - -const { Domains } = require('#models'); - -async function removeInvite(ctx, next) { - // ctx.request.body.email - // ctx.query.email - const email = ctx.request.body.email || ctx.query.email; - if (!isSANB(email) || !isEmail(email)) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_EMAIL'))); - ctx.state.domain = await Domains.findById(ctx.state.domain._id); - if (!ctx.state.domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - // remove invite - ctx.state.domain.invites = ctx.state.domain.invites.filter( - (invite) => invite.email.toLowerCase() !== email.toLowerCase() - ); - ctx.state.domain.locale = ctx.locale; - ctx.state.domain.skip_verification = true; - ctx.state.domain = await ctx.state.domain.save(); - - if (ctx.api) return next(); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = removeInvite; diff --git a/app/controllers/web/my-account/remove-member.js b/app/controllers/web/my-account/remove-member.js deleted file mode 100644 index 737b5b98a..000000000 --- a/app/controllers/web/my-account/remove-member.js +++ /dev/null @@ -1,57 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); - -const { Domains } = require('#models'); - -async function removeMember(ctx, next) { - // ctx.params.member_id - if (!isSANB(ctx.params.member_id)) - return ctx.throw(Boom.notFound(ctx.translateError('INVALID_USER'))); - - const member = ctx.state.domain.members.find( - (member) => member.user && member.user.id === ctx.params.member_id - ); - - if (!member || !member.user) - return ctx.throw(Boom.notFound(ctx.translateError('INVALID_USER'))); - - // ensure that no aliases created with this user being removed - // (they need re-assigned first before the user can be removed) - const memberAliases = ctx.state.domain.aliases.filter( - (alias) => alias.user && alias.user.id === member.user.id - ); - - if (memberAliases.length > 0) - return ctx.throw( - Boom.badRequest(ctx.translateError('ALIASES_NEED_REASSIGNED')) - ); - - ctx.state.domain = await Domains.findById(ctx.state.domain._id); - if (!ctx.state.domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - ctx.state.domain.members = ctx.state.domain.members.filter( - (member) => member.user.toString() !== ctx.params.member_id - ); - ctx.state.domain.locale = ctx.locale; - ctx.state.domain.client = ctx.client; - ctx.state.domain = await ctx.state.domain.save(); - - if (ctx.api) return next(); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = removeMember; diff --git a/app/controllers/web/my-account/remove.js b/app/controllers/web/my-account/remove.js deleted file mode 100644 index 22f6743b6..000000000 --- a/app/controllers/web/my-account/remove.js +++ /dev/null @@ -1,29 +0,0 @@ -const Boom = require('@hapi/boom'); - -const { Users } = require('#models'); - -async function remove(ctx) { - const adminDomains = ctx.state.domains.filter( - (domain) => domain.group === 'admin' - ); - if (adminDomains.length > 0) - return ctx.throw( - Boom.badRequest(ctx.translateError('ACCOUNT_DELETE_HAS_DOMAINS')) - ); - await Users.findByIdAndRemove(ctx.state.user._id); - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('ACCOUNT_DELETE_SUCCESSFUL'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - const redirectTo = ctx.state.l(); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; -} - -module.exports = remove; diff --git a/app/controllers/web/my-account/resend-email-change.js b/app/controllers/web/my-account/resend-email-change.js deleted file mode 100644 index 7734ddc9e..000000000 --- a/app/controllers/web/my-account/resend-email-change.js +++ /dev/null @@ -1,75 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const cryptoRandomString = require('crypto-random-string'); -const dayjs = require('dayjs-with-plugins'); - -const emailHelper = require('#helpers/email'); -const config = require('#config'); - -async function resendEmailChange(ctx) { - // if no email change request exists throw an error - if (!ctx.state.user[config.userFields.changeEmailNewAddress]) - throw ctx.throw( - Boom.badRequest(ctx.translateError('EMAIL_CHANGE_DOES_NOT_EXIST')) - ); - - // reset the reset token and expiry - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] = dayjs() - .add(config.changeEmailTokenTimeoutMs, 'milliseconds') - .toDate(); - ctx.state.user[config.userFields.changeEmailToken] = - await cryptoRandomString.async({ - length: 32 - }); - - // save the user - ctx.state.user = await ctx.state.user.save(); - - try { - await emailHelper({ - template: 'change-email', - message: { - to: ctx.state.user[config.userFields.changeEmailNewAddress] - }, - locals: { - user: _.pick(ctx.state.user, [ - config.userFields.changeEmailTokenExpiresAt, - config.userFields.changeEmailNewAddress, - config.passportLocalMongoose.usernameField - ]), - link: `${config.urls.web}/my-account/change-email/${ - ctx.state.user[config.userFields.changeEmailToken] - }` - } - }); - } catch (err) { - ctx.logger.fatal(err); - // reset if there was an error - try { - ctx.state.user[config.userFields.changeEmailToken] = null; - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] = null; - ctx.state.user[config.userFields.changeEmailNewAddress] = null; - ctx.state.user = await ctx.state.user.save(); - } catch (err) { - ctx.logger.error(err); - } - - throw Boom.badRequest(ctx.translateError('EMAIL_FAILED_TO_SEND')); - } - - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('EMAIL_CHANGE_SENT'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = resendEmailChange; diff --git a/app/controllers/web/my-account/reset-api-token.js b/app/controllers/web/my-account/reset-api-token.js deleted file mode 100644 index 0e0d36b73..000000000 --- a/app/controllers/web/my-account/reset-api-token.js +++ /dev/null @@ -1,22 +0,0 @@ -const config = require('#config'); - -async function resetAPIToken(ctx) { - ctx.state.user[config.userFields.apiToken] = null; - ctx.state.user = await ctx.state.user.save(); - - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = resetAPIToken; diff --git a/app/controllers/web/my-account/retrieve-alias.js b/app/controllers/web/my-account/retrieve-alias.js deleted file mode 100644 index 254d3eaf7..000000000 --- a/app/controllers/web/my-account/retrieve-alias.js +++ /dev/null @@ -1,37 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); - -function retrieveAlias(ctx, next) { - if (!isSANB(ctx.params.alias_id)) - return ctx.throw(Boom.notFound(ctx.translateError('ALIAS_DOES_NOT_EXIST'))); - ctx.state.alias = ctx.state.domain.aliases.find( - (alias) => - alias.id === ctx.params.alias_id || alias.name === ctx.params.alias_id - ); - if (!ctx.state.alias) - return ctx.throw(Boom.notFound(ctx.translateError('ALIAS_DOES_NOT_EXIST'))); - - if (ctx.api) return next(); - if ( - ctx.pathWithoutLocale === - `/my-account/domains/${ctx.state.domain.name}/aliases/${ctx.state.alias.id}` - ) { - ctx.state.breadcrumbHeaderCentered = true; - ctx.state.breadcrumbs.push( - { - name: ctx.state.t('Aliases'), - href: ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/aliases` - ) - }, - { - header: ctx.state.t('Edit Alias'), - name: `${ctx.state.alias.name}@${ctx.state.domain.name}` - } - ); - } - - return next(); -} - -module.exports = retrieveAlias; diff --git a/app/controllers/web/my-account/retrieve-aliases.js b/app/controllers/web/my-account/retrieve-aliases.js deleted file mode 100644 index 2fceb5a25..000000000 --- a/app/controllers/web/my-account/retrieve-aliases.js +++ /dev/null @@ -1,40 +0,0 @@ -const RE2 = require('re2'); -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); - -function retrieveAliases(ctx, next) { - // if there aren't any aliases yet - // then prompt the user to create one and flash a message - // otherwise take them to the next middleware - if (ctx.api || ctx.state.domain.aliases.length > 0) { - // - // search functionality (with RegExp support) - // - if (isSANB(ctx.query.name)) - ctx.state.domain.aliases = ctx.state.domain.aliases.filter((alias) => - new RE2(_.escapeRegExp(ctx.query.name)).test(alias.name) - ); - - if (isSANB(ctx.query.recipient)) { - const recipientRegex = new RE2(_.escapeRegExp(ctx.query.recipient)); - ctx.state.domain.aliases = ctx.state.domain.aliases.filter((alias) => - alias.recipients.some((recipient) => recipientRegex.test(recipient)) - ); - } - - return next(); - } - - ctx.flash('custom', { - title: ctx.translate('ADD_ALIAS'), - text: ctx.translate('NO_ALIASES_EXIST'), - type: 'info', - toast: true, - showConfirmButton: false, - timer: 5000, - position: 'top' - }); - ctx.redirect(`/my-account/domains/${ctx.state.domain.name}/aliases/new`); -} - -module.exports = retrieveAliases; diff --git a/app/controllers/web/my-account/retrieve-billing.js b/app/controllers/web/my-account/retrieve-billing.js deleted file mode 100644 index 260f0f5be..000000000 --- a/app/controllers/web/my-account/retrieve-billing.js +++ /dev/null @@ -1,20 +0,0 @@ -const { Payments } = require('#models'); - -async function retrieveBilling(ctx, next) { - // check ctx.query.plan and prompt users to enter payment (before upgrading) - // if user lands on page and they don't have payment entered and upgraded plan then prompt them and alert - // render a billing history - // prompt users for credit card - ctx.state.payments = await Payments.find({ user: ctx.state.user._id }) - .sort('-invoice_at') - .exec(); - // localize the descriptions - ctx.state.payments = ctx.state.payments.map((payment) => { - payment.locale = ctx.locale; - return payment; - }); - - return next(); -} - -module.exports = retrieveBilling; diff --git a/app/controllers/web/my-account/retrieve-domain-billing.js b/app/controllers/web/my-account/retrieve-domain-billing.js deleted file mode 100644 index a865d6ff4..000000000 --- a/app/controllers/web/my-account/retrieve-domain-billing.js +++ /dev/null @@ -1,989 +0,0 @@ -const Boom = require('@hapi/boom'); -const Stripe = require('stripe'); -const _ = require('lodash'); -const accounting = require('accounting'); -const checkoutNodeJssdk = require('@paypal/checkout-server-sdk'); -const countryList = require('country-list'); -const dayjs = require('dayjs-with-plugins'); -const isSANB = require('is-string-and-not-blank'); -const ms = require('ms'); - -const env = require('#config/env'); -const config = require('#config'); -const emailHelper = require('#helpers/email'); -const { paypalAgent } = require('#helpers/paypal'); -const logger = require('#helpers/logger'); -const { Domains, Payments } = require('#models'); - -const { - STRIPE_MAPPING, - STRIPE_PRODUCTS, - paypalCheckoutSdkConfig, - PAYPAL_PLAN_MAPPING -} = config.payments; - -const payPalClient = new checkoutNodeJssdk.core.PayPalHttpClient( - paypalCheckoutSdkConfig -); - -// country list with USA at the top -const USA = 'United States of America'; -const countries = countryList.getNames().sort(); -countries.splice(countries.indexOf(USA), 1); -countries.unshift(USA); - -const stripe = new Stripe(env.STRIPE_SECRET_KEY); - -// eslint-disable-next-line complexity -async function retrieveDomainBilling(ctx) { - const isAccountUpgrade = - ctx.pathWithoutLocale === '/my-account/billing/upgrade'; - const isMakePayment = - ctx.pathWithoutLocale === '/my-account/billing/make-payment'; - const redirectTo = ctx.state.l( - isAccountUpgrade || isMakePayment - ? '/my-account/billing' - : `/my-account/domains/${ctx.state.domain.name}` - ); - const originalPlanExpiresAt = ctx.state.user[config.userFields.planExpiresAt]; - const originalPlan = ctx.state.user.plan; - - if (isMakePayment) - if (ctx.state.user.plan === 'free') - throw ctx.translateError('INVALID_PLAN'); - else ctx.query.plan = ctx.state.user.plan; - - try { - if ( - !isSANB(ctx.query.plan) || - !['free', 'enhanced_protection', 'team'].includes(ctx.query.plan) - ) - throw ctx.translateError('INVALID_PLAN'); - - let domain; - if (!isAccountUpgrade && !isMakePayment) { - domain = await Domains.findById(ctx.state.domain._id); - - if (!domain) throw ctx.translateError('DOMAIN_DOES_NOT_EXIST'); - - // set locale on domain for translation - domain.locale = ctx.locale; - - // handle edge case if user had multiple tabs open and already upgraded - if (ctx.query.plan === domain.plan) - throw ctx.translateError('PLAN_ALREADY_ACTIVE'); - } - - // - // validate that the user can actually downgrade to the desired plan - // - if (isAccountUpgrade) { - const adminDomains = ctx.state.domains.filter((domain) => - domain.members.some( - (member) => - member.user.id === ctx.state.user.id && member.group === 'admin' - ) - ); - - const errors = []; - - for (const domain of adminDomains) { - if (domain.plan === 'free') continue; - - // determine what plans are required - const validPlans = - domain.plan === 'team' ? ['team'] : ['enhanced_protection', 'team']; - let isValid = false; - - for (const member of domain.members) { - // return early if the member is not an admin (irrelevant) - if (member.group !== 'admin') continue; - - // if the user did not exist then return early - if (!member.user || !member.user.id) { - logger.fatal( - new Error(`Member in ${domain.name} no longer exists`) - ); - continue; - } - - // use the new/latest plan passed in the `user` argument (as opposed to what exists) - // (e.g. this method `ensureUserHasValidPlan` is called before saving a user's plan change) - const memberPlan = - member.user.id === ctx.state.user.id - ? ctx.query.plan - : member.user.plan; - - if (validPlans.includes(memberPlan)) { - isValid = true; - break; - } - } - - if (!isValid) - errors.push( - ctx.translateError( - 'DOMAIN_PLAN_UPGRADE_REQUIRED', - domain.name, - ctx.translate(domain.plan.toUpperCase()), - ctx.state.l( - `/my-account/domains/${domain.name}/billing?plan=${domain.plan}` - ) - ) - ); - } - - if (errors.length > 0) { - if (errors.length === 1) throw Boom.badRequest(errors[0].message); - // TODO: translate stuff like this - throw Boom.badRequest(` -

The following errors occurred:

-
  • ${errors - .map((e) => e.message) - .join('
  • ')}
    • - `); - } - } - - // render a page where user can select from a dropdown how long they want to pay for - if ( - // TODO: when user downgrades plans, e.g. to free or to e.p. - // then we need to cancel their existing subscription - ctx.query.plan !== 'free' && - !isSANB(ctx.query.session_id) && - !isSANB(ctx.query.paypal_order_id) && - !isSANB(ctx.query.paypal_subscription_id) && - (isAccountUpgrade || - isMakePayment || - (ctx.query.plan === 'team' && ctx.state.user.plan !== 'team') || - (ctx.query.plan === 'enhanced_protection' && - !['team', 'enhanced_protection'].includes(ctx.state.user.plan))) - ) { - ctx.state.breadcrumbHeaderCentered = true; - if (isAccountUpgrade) { - ctx.state.breadcrumbs.pop(); - ctx.state.breadcrumbs.push({ - name: ctx.translate('UPGRADE'), - header: ctx.translate('BILLING') - }); - } - - return ctx.render('my-account/domains/billing'); - } - - // set/upgrade the user or domain's plan - if (isAccountUpgrade) { - ctx.state.user.plan = ctx.query.plan; - await Domains.ensureUserHasValidPlan(ctx.state.user, ctx.locale); - } else if (!isMakePayment) { - domain.plan = ctx.query.plan; - } - - // - // stripe - // - // NOTE: we don't refund the user since we can catch errors as they happen - // and stripe doesn't refund transaction fees, so it's better to - // handle this manually since it's such a rare edge case rather than - // have an automated refund here if some error were to occur - // - if (isSANB(ctx.query.session_id)) { - // if the querystring contained a Stripe checkout session ID then verify it and upgrade user - const session = await stripe.checkout.sessions.retrieve( - ctx.query.session_id - ); - - // validate session exists - if (!session) throw ctx.translateError('UNKNOWN_ERROR'); - - ctx.logger.info('stripe.checkout.sessions.retrieve', { session }); - - // if payment status was not paid then throw an error - if (session.payment_status !== 'paid') - throw ctx.translateError('UNKNOWN_ERROR'); - - // store customer - ctx.state.user[config.userFields.stripeCustomerID] = session.customer; - - // if they upgraded their plan then store it on the user object - if (!isMakePayment) ctx.state.user.plan = ctx.query.plan; - - // look at the line items - const lineItems = await stripe.checkout.sessions.listLineItems( - session.id - ); - - if (!Array.isArray(lineItems.data) || lineItems.data.length !== 1) - throw ctx.translateError('UNKNOWN_ERROR'); - - // look up the product associated with the line item - // (it should match ctx.query.plan but this is a safeguard) - const productToPlan = STRIPE_PRODUCTS[lineItems.data[0].price.product]; - - if ( - !isSANB(productToPlan) || - !['team', 'enhanced_protection'].includes(productToPlan) - ) - throw ctx.translateError('UNKNOWN_ERROR'); - - // if the plan doesn't match the querystring throw an error (safeguard) - if (productToPlan !== ctx.query.plan) - throw ctx.translateError('UNKNOWN_ERROR'); - - // set planExpiresAt based off lineItems.data[0].price.id conversion to time - const type = - session.mode === 'subscription' ? 'subscription' : 'one-time'; - const mapping = STRIPE_MAPPING[ctx.query.plan][type]; - const key = _.keys(mapping).find( - (key) => - STRIPE_MAPPING[ctx.query.plan][type][key] === - lineItems.data[0].price.id - ); - if (!isSANB(key)) throw ctx.translateError('UNKNOWN_ERROR'); - - const now = new Date(); - // if for whatever reason they never had a plan set at date then set one - - if (!_.isDate(ctx.state.user[config.userFields.planSetAt])) - ctx.state.user[config.userFields.planSetAt] = now; - - // if they're just making a one time payment we want to simply add to their account - if (isMakePayment) { - // ensure there was an existing plan expiration, otherwise set one for safety - if (!_.isDate(ctx.state.user[config.userFields.planExpiresAt])) - ctx.state.user[config.userFields.planExpiresAt] = now; - // now add to the plan expiration the length in time necessary - ctx.state.user[config.userFields.planExpiresAt] = dayjs( - ctx.state.user[config.userFields.planExpiresAt] - ) - .add(ms(key), 'millisecond') - .toDate(); - } else { - // set planSetAt (since we're changing plans or making an upgrade/change to the plan) - ctx.state.user[config.userFields.planSetAt] = now; - // so now `key` looks like `"1m"` which we can use with `ms` to add duration to the plan - ctx.state.user[config.userFields.planExpiresAt] = dayjs( - ctx.state.user[config.userFields.planSetAt] - ) - .add(ms(key), 'millisecond') - .toDate(); - } - - let method = 'unknown'; - let expMonth; - let expYear; - let last4; - - if (session.mode === 'subscription') { - const subscription = await stripe.subscriptions.retrieve( - session.subscription - ); - if (!subscription) throw ctx.translateError('UNKNOWN_ERROR'); - // if user already has a subscription then switch them and cancel the old one - if (isSANB(ctx.state.user[config.userFields.stripeSubscriptionID])) { - try { - await stripe.subscriptions.del( - ctx.state.user[config.userFields.stripeSubscriptionID] - ); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error deleting Stripe subscription ID ${ - ctx.state.user[config.userFields.stripeSubscriptionID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - } - } - - // figure out what payment method they used and store it in the payment history - // (so they can tell which card they used just from a simple glance at billing history) - try { - // note that we can use `subscription.default_payment_method` as the source for - // the payment used on the subscription because this is a snapshot the moment it was created - const paymentMethod = await stripe.paymentMethods.retrieve( - subscription.default_payment_method - ); - - if (!paymentMethod) throw ctx.translateError('UNKNOWN_ERROR'); - - ({ - brand: method, - exp_month: expMonth, - exp_year: expYear, - last4 - } = paymentMethod.card); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error retrieving Stripe Payment Method ID ${subscription.default_payment_method} for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - // - // NOTE: there is an automated job "check-unknown-payment-methods" - // which alerts us automatically about "unknown" methods - // - } - - // save the new subscription ID to their account (so they can 1-click cancel subscriptions) - ctx.state.user[config.userFields.stripeSubscriptionID] = - subscription.id; - } else { - let paymentIntent; - try { - paymentIntent = await stripe.paymentIntents.retrieve( - session.payment_intent - ); - if (!paymentIntent) throw ctx.translateError('UNKNOWN_ERROR'); - - const paymentMethod = await stripe.paymentMethods.retrieve( - paymentIntent.payment_method - ); - if (!paymentMethod) throw ctx.translateError('UNKNOWN_ERROR'); - - ({ - brand: method, - exp_month: expMonth, - exp_year: expYear, - last4 - } = paymentMethod.card); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: paymentIntent - ? `Error retrieving Stripe Payment Method ID ${paymentIntent.payment_method} for ${ctx.state.user.email}` - : `Stripe Payment Intent/Method Error for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - // - // NOTE: there is an automated job "check-unknown-payment-methods" - // which alerts us automatically about "unknown" methods - // - } - } - - // these all occur in parallel, but the only one we need to work is saving domain - let [payment, user] = await Promise.all([ - Payments.create({ - user: ctx.state.user._id, - reference: session.client_reference_id, - amount: session.amount_total, - method, - exp_month: expMonth, - exp_year: expYear, - last4, - stripe_session_id: session.id, - duration: ms(key), - plan: ctx.query.plan, - kind: session.mode === 'subscription' ? 'subscription' : 'one-time', - invoice_at: now - }), - // try to save the customer info to the account - ctx.state.user.save() - ]); - - // log the payment just for sanity - ctx.logger.info('payment created', { payment }); - - // cancel the user's paypal subscription if they had one - // and if the session.mode was equal to subscription - if ( - session.mode === 'subscription' && - isSANB(ctx.state.user[config.userFields.paypalSubscriptionID]) - ) { - try { - await paypalAgent.post( - `/v1/billing/subscriptions/${ - ctx.state.user[config.userFields.paypalSubscriptionID] - }/cancel` - ); - ctx.state.user[config.userFields.paypalSubscriptionID] = null; - user = await ctx.state.user.save(); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error deleting PayPal subscription ID ${ - ctx.state.user[config.userFields.paypalSubscriptionID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - } - } - - // re-assign if needed - ctx.state.user = user; - } - - // - // paypal (one-time payment) - // - if (isSANB(ctx.query.paypal_order_id)) { - const order = await payPalClient.execute( - new checkoutNodeJssdk.orders.OrdersGetRequest(ctx.query.paypal_order_id) - ); - ctx.logger.info('OrdersGetRequest', { order }); - if ( - !_.isObject(order) || - !_.isObject(order.result) || - order.result.intent !== 'CAPTURE' || - order.result.status !== 'APPROVED' || - !_.isArray(order.result.purchase_units) || - _.isEmpty(order.result.purchase_units) || - !_.isObject(order.result.payer) - ) - throw ctx.translateError('UNKNOWN_ERROR'); - - // - // `order` object looks like this: - // - // console.log(JSON.stringify(order, null, 2)); - // - /* - { - "statusCode": 200, - "headers": { - "cache-control": "max-age=0, no-cache, no-store, must-revalidate", - "content-length": "1224", - "content-type": "application/json", - "date": "Mon, 16 Nov 2020 03:18:18 GMT", - "paypal-debug-id": "c35e05401f88b", - "connection": "close" - }, - "result": { - "id": "9YC624648R1052403", - "intent": "CAPTURE", - "status": "APPROVED", - "purchase_units": [ - { - "reference_id": "R58F7X", - "amount": { - "currency_code": "USD", - "value": "108.00", - "breakdown": { - "item_total": { - "currency_code": "USD", - "value": "108.00" - } - } - }, - "payee": { - "email_address": "forwardemail-facilitator@gmail.com", - "merchant_id": "8CV4WN2C9QDA4", - "display_data": { - "brand_name": "Forward Email" - } - }, - "description": "One-time payment for a year of the Team plan.", - "custom_id": "TEAM", - "soft_descriptor": "TEAM", - "items": [ - { - "name": "Team", - "unit_amount": { - "currency_code": "USD", - "value": "108.00" - }, - "quantity": "1", - "description": "One-time payment for a year of the Team plan.", - "sku": "TEAM", - "category": "DIGITAL_GOODS" - } - ] - } - ], - "payer": { - "name": { - "given_name": "John", - "surname": "Doe" - }, - "email_address": "sb-ciwvw3496408@personal.example.com", - "payer_id": "D9GQHN74A9CV8", - "address": { - "country_code": "US" - } - }, - "create_time": "2020-11-16T03:12:48Z", - "links": [ - { - "href": "https://api.sandbox.paypal.com/v2/checkout/orders/9YC624648R1052403", - "rel": "self", - "method": "GET" - }, - { - "href": "https://api.sandbox.paypal.com/v2/checkout/orders/9YC624648R1052403", - "rel": "update", - "method": "PATCH" - }, - { - "href": "https://api.sandbox.paypal.com/v2/checkout/orders/9YC624648R1052403/capture", - "rel": "capture", - "method": "POST" - } - ] - } - } - */ - - // store customer - ctx.state.user[config.userFields.paypalPayerID] = - order.result.payer.payer_id; - - // validate plans matched up - if ( - !['team', 'enhanced_protection'].includes( - order.result.purchase_units[0].custom_id.toLowerCase() - ) || - order.result.purchase_units[0].custom_id.toLowerCase() !== - ctx.query.plan - ) - throw ctx.translateError('UNKNOWN_ERROR'); - - // if they upgraded their plan then store it on the user object - if (!isMakePayment) ctx.state.user.plan = ctx.query.plan; - - // we will take the amount and divide it by the cost per the custom_id - // in order to determine the duration the customer purchased/added - const amount = Number( - order.result.purchase_units[0].items[0].unit_amount.value - ); - const months = Math.round(amount / (ctx.query.plan === 'team' ? 9 : 3)); - if (!_.isFinite(months) || months < 1) - throw ctx.translateError('UNKNOWN_ERROR'); - - let now = new Date(order.result.create_time); - if (!_.isDate(now)) now = new Date(); - - // if for whatever reason they never had a plan set at date then set one - if (!_.isDate(ctx.state.user[config.userFields.planSetAt])) - ctx.state.user[config.userFields.planSetAt] = now; - - // if they're just making a one time payment we want to simply add to their account - if (isMakePayment) { - // ensure there was an existing plan expiration, otherwise set one for safety - if (!_.isDate(ctx.state.user[config.userFields.planExpiresAt])) - ctx.state.user[config.userFields.planExpiresAt] = now; - // now add to the plan expiration the length in time necessary - ctx.state.user[config.userFields.planExpiresAt] = dayjs( - ctx.state.user[config.userFields.planExpiresAt] - ) - .add(months, 'month') - .toDate(); - } else { - // set planSetAt (since we're changing plans or making an upgrade/change to the plan) - ctx.state.user[config.userFields.planSetAt] = now; - ctx.state.user[config.userFields.planExpiresAt] = dayjs( - ctx.state.user[config.userFields.planSetAt] - ) - .add(months, 'month') - .toDate(); - } - - // capture the user's payment (towards the end in case something else went wrong) - const request = new checkoutNodeJssdk.orders.OrdersCaptureRequest( - order.result.id - ); - request.requestBody({}); // seems odd to have to do this - const response = await payPalClient.execute(request); - ctx.logger.info('OrdersCaptureRequest', { - order, - paypal_response: response - }); - if ( - !_.isObject(response) || - !_.isObject(response.result) || - response.result.status !== 'COMPLETED' - ) - throw ctx.translateError('UNKNOWN_ERROR'); - - // these all occur in parallel, but the only one we need to work is saving domain - const [payment, user] = await Promise.all([ - Payments.create({ - user: ctx.state.user._id, - reference: order.result.purchase_units[0].reference_id, - amount: Number.parseInt(amount * 100, 10), // convert to cents for consistency with stripe - method: 'paypal', - duration: dayjs(now).add(months, 'month').diff(new Date()), - plan: ctx.query.plan, - kind: 'one-time', - paypal_order_id: order.result.id, - invoice_at: now - }), - // try to save the customer info to the account - ctx.state.user.save() - ]); - - // log the payment just for sanity - ctx.logger.info('payment created', { payment }); - - // re-assign if needed - ctx.state.user = user; - } - - // NOTE: handle lifetime payments for both stripe and paypal one-time payments - - // - // paypal (subscription payment) - // - if (isSANB(ctx.query.paypal_subscription_id)) { - // if user already has a subscription then switch them and cancel the old one - if (isSANB(ctx.state.user[config.userFields.paypalSubscriptionID])) { - try { - await paypalAgent.post( - `/v1/billing/subscriptions/${ - ctx.state.user[config.userFields.paypalSubscriptionID] - }/cancel` - ); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error deleting PayPal subscription ID ${ - ctx.state.user[config.userFields.paypalSubscriptionID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - } - } - - const { body } = await paypalAgent.get( - `/v1/billing/subscriptions/${ctx.query.paypal_subscription_id}` - ); - - // validate subscription is active - if ( - !_.isObject(body) || - body.status !== 'ACTIVE' || - !isSANB(body.id) || - !isSANB(body.plan_id) || - !_.isObject(body.subscriber) || - !isSANB(body.subscriber.payer_id) || - !_.isObject(body.billing_info) || - !_.isObject(body.billing_info.last_payment) || - !_.isObject(body.billing_info.last_payment.amount) || - !isSANB(body.billing_info.last_payment.amount.value) - ) - throw ctx.translateError('UNKNOWN_ERROR'); - - // store customer - ctx.state.user[config.userFields.paypalPayerID] = - body.subscriber.payer_id; - - // save the new subscription ID to their account - ctx.state.user[config.userFields.paypalSubscriptionID] = body.id; - - // validate plans matched up - const mapping = PAYPAL_PLAN_MAPPING[ctx.query.plan]; - if (!_.isObject(mapping)) throw ctx.translateError('UNKNOWN_ERROR'); - let duration; - for (const key of Object.keys(mapping)) { - if (mapping[key] === body.plan_id) { - duration = key; - break; - } - } - - if (!duration) throw ctx.translateError('UNKNOWN_ERROR'); - - // if they upgraded their plan then store it on the user object - if (!isMakePayment) ctx.state.user.plan = ctx.query.plan; - - // parse the amount for later - const amount = Number(body.billing_info.last_payment.amount.value); - if (!_.isFinite(amount) || amount <= 0) - throw ctx.translateError('UNKNOWN_ERROR'); - - // get the date - let now = new Date(body.create_time); - if (!_.isDate(now)) now = new Date(); - - // set planSetAt (since we're changing plans or making an upgrade/change to the plan) - ctx.state.user[config.userFields.planSetAt] = now; - // so now `key` looks like `"1m"` which we can use with `ms` to add duration to the plan - ctx.state.user[config.userFields.planExpiresAt] = dayjs( - ctx.state.user[config.userFields.planSetAt] - ) - .add(ms(duration), 'millisecond') - .toDate(); - - // these all occur in parallel, but the only one we need to work is saving domain - let [payment, user] = await Promise.all([ - Payments.create({ - user: ctx.state.user._id, - // NOTE: no `reference` (?) - amount: Number.parseInt(amount * 100, 10), // convert to cents for consistency with stripe - method: 'paypal', - duration: dayjs(now) - .add(ms(duration), 'millisecond') - .diff(new Date()), - plan: ctx.query.plan, - kind: 'subscription', - paypal_subscription_id: body.id, - invoice_at: now - }), - // try to save the customer info to the account - ctx.state.user.save() - ]); - - // cancel the user's stripe subscription if they had one - if (isSANB(ctx.state.user[config.userFields.stripeSubscriptionID])) { - try { - await stripe.subscriptions.del( - ctx.state.user[config.userFields.stripeSubscriptionID] - ); - ctx.state.user[config.userFields.stripeSubscriptionID] = null; - user = await ctx.state.user.save(); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error deleting Stripe subscription ID ${ - ctx.state.user[config.userFields.stripeSubscriptionID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - } - } - - // log the payment just for sanity - ctx.logger.info('payment created', { payment }); - - // re-assign if needed - ctx.state.user = user; - } - - // save the user or domain's new plan - // - // NOTE: we do a double save here on user object which we might not want - // - if (isAccountUpgrade || isMakePayment) - ctx.state.user = await ctx.state.user.save(); - else { - domain.locale = ctx.locale; - domain.client = ctx.client; - ctx.state.domain = await domain.save(); - } - - // if ctx.query.plan was free and it was an account billing change - // then we know that we need to cancel any necessary subscriptions - // BUT this must come after the user is saved (due to pre-validation hooks) - // (we can optimize this later with a `validate()` method being invoked instead of `save()`) - if ( - isAccountUpgrade && - ctx.query.plan === 'free' && - (isSANB(ctx.state.user[config.userFields.stripeSubscriptionID]) || - isSANB(ctx.state.user[config.userFields.paypalSubscriptionID])) - ) { - await Promise.all([ - // stripe - isSANB(ctx.state.user[config.userFields.stripeSubscriptionID]) - ? (async () => { - try { - await stripe.subscriptions.del( - ctx.state.user[config.userFields.stripeSubscriptionID] - ); - // set the current value to null - ctx.state.user[config.userFields.stripeSubscriptionID] = null; - // save the user again - await ctx.state.user.save(); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error deleting Stripe subscription ID ${ - ctx.state.user[config.userFields.stripeSubscriptionID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - } - })() - : Promise.resolve(), - // paypal - isSANB(ctx.state.user[config.userFields.paypalSubscriptionID]) - ? (async () => { - try { - await paypalAgent.post( - `/v1/billing/subscriptions/${ - ctx.state.user[config.userFields.paypalSubscriptionID] - }/cancel` - ); - } catch (err) { - ctx.logger.fatal(err); - // email admins here - try { - await emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `Error deleting PayPal subscription ID ${ - ctx.state.user[config.userFields.paypalSubscriptionID] - } for ${ctx.state.user.email}` - }, - locals: { message: err.message } - }); - } catch (err) { - ctx.logger.fatal(err); - } - } - })() - : Promise.resolve() - ]); - } - - // flash message and redirect - if (!ctx.api) - ctx.flash( - 'success', - isMakePayment - ? ctx.translate('ONE_TIME_PAYMENT_SUCCESSFUL') - : ctx.translate(`${ctx.query.plan.toUpperCase()}_PLAN`) - ); - - // pro-rated refund manual email if necessary based off: - // `originalPlanExpiresAt` (if it was a valid date) - // `originalPlan` (if it swapped between team <-> enhanced_protection - // (do things that don't scale) - if ( - isAccountUpgrade && - !isMakePayment && - _.isDate(originalPlanExpiresAt) && - ctx.query.plan !== originalPlan && - originalPlan !== 'free' - ) { - // get the total number of days that need pro-rated (if negative then none) - // (note that we add one day as a buffer to ensure they get a full refund) - const diff = dayjs(originalPlanExpiresAt).diff(new Date(), 'days') + 1; - if (diff > 0) { - const cost = originalPlan === 'team' ? 9 : 3; - const amount = Number(accounting.toFixed((cost / 30) * diff, 2)); - const message = ctx.translate( - 'REFUND_PROCESSING', - accounting.formatMoney(amount) - ); - ctx.logger.info('refund', { diff, cost, amount, message }); - if (ctx.accepts('html')) ctx.flash('info', message); - // email admins here (in the background) - emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: 'A Customer Needs Refunded!' - }, - locals: { - message: `

      ${ctx.state.user.email} just checked out with the "${ctx.query.plan}" plan and was on the "${originalPlan}" plan: ${message}` - } - }) - .then(() => {}) - .catch((err) => { - ctx.logger.fatal(err); - }); - } - } - - // email admins here (in the background) - ctx.logger.info('checkout'); - emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: 'A Customer Just Checked Out!' - }, - locals: { - message: `

      ${ctx.state.user.email} just checked out with the "${ctx.query.plan}" plan` - } - }) - .then(() => {}) - .catch((err) => { - ctx.logger.fatal(err); - }); - - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; - } catch (err) { - ctx.logger.fatal(err); - - // email admins here (in the background) - emailHelper({ - template: 'alert', - message: { - to: config.email.message.from, - subject: `An error occurred for ${ctx.state.user.email} on billing` - }, - locals: { - message: `

      URL: ${ctx.url}

      Error Message: ${err.message}

      ` - } - }) - .then(() => {}) - .catch((err) => { - ctx.logger.fatal(err); - }); - - if (ctx.accepts('html')) { - ctx.flash('error', err.message); - return ctx.redirect(redirectTo); - } - - ctx.throw(Boom.badRequest(err.message)); - } -} - -module.exports = retrieveDomainBilling; diff --git a/app/controllers/web/my-account/retrieve-domain.js b/app/controllers/web/my-account/retrieve-domain.js deleted file mode 100644 index 6fc3154e1..000000000 --- a/app/controllers/web/my-account/retrieve-domain.js +++ /dev/null @@ -1,126 +0,0 @@ -const path = require('path'); - -const Boom = require('@hapi/boom'); -const Meta = require('koa-meta'); -const isSANB = require('is-string-and-not-blank'); -const pug = require('pug'); -const { parse } = require('node-html-parser'); - -const config = require('#config'); -const logger = require('#helpers/logger'); - -const meta = new Meta(config.meta, logger); - -async function retrieveDomain(ctx, next) { - if (!isSANB(ctx.params.domain_id) && !isSANB(ctx.request.body.domain)) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - const id = isSANB(ctx.params.domain_id) - ? ctx.params.domain_id - : ctx.request.body.domain; - - ctx.state.domain = ctx.state.domains.find((domain) => - [domain.id, domain.name].includes(id) - ); - - // check if domain exists, and f it doesn't then check - // if we have a pending invite - if (!ctx.state.domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - // if it's an API request then return early - if (ctx.api) return next(); - - // - // set breadcrumbs - // - ctx.state.breadcrumbs = [ - 'my-account', - { - name: ctx.state.t('Domains'), - header: ctx.state.domain.name, - href: ctx.state.l('/my-account/domains') - }, - { - name: ctx.state.domain.name, - href: - ctx.state.domain.group === 'admin' - ? ctx.state.l(`/my-account/domains/${ctx.state.domain.name}`) - : null - } - ]; - - // eslint-disable-next-line unicorn/prefer-switch - if ( - ctx.pathWithoutLocale === - `/my-account/domains/${ctx.state.domain.name}/aliases` - ) - ctx.state.breadcrumbs.push('aliases'); - else if ( - ctx.pathWithoutLocale === - `/my-account/domains/${ctx.state.domain.name}/advanced-settings` - ) - ctx.state.breadcrumbs.push('advanced-settings'); - else if ( - ctx.pathWithoutLocale === - `/my-account/domains/${ctx.state.domain.name}/aliases/new` - ) { - ctx.state.breadcrumbHeaderCentered = true; - ctx.state.breadcrumbs.push( - { - name: ctx.state.t('Aliases'), - href: ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/aliases` - ) - }, - { - name: ctx.state.t('Add Alias') - } - ); - } else if ( - ctx.pathWithoutLocale === - `/my-account/domains/${ctx.state.domain.name}/billing` - ) - ctx.state.breadcrumbs.push('billing'); - - // load seo metadata - let data = {}; - try { - data = meta.getByPath(ctx.pathWithoutLocale || ctx.path, ctx.request.t); - } catch (err) { - logger.error(err); - data = meta.getByPath('/', ctx.request.t); - } - - Object.assign(ctx.state.meta, data); - - // dynamically load the DNS Managment by Registrar table from FAQ - try { - const html = pug.renderFile( - path.join(config.views.root, 'faq', 'index.pug'), - // make flash a noop so we don't interfere with messages/session - { - ...ctx.state, - flash() { - return {}; - } - } - ); - - // expose it to the view - const root = parse(html); - ctx.state.modalFAQTable = root.querySelector( - '#table-dns-management-by-registrar' - ).outerHTML; - } catch (err) { - ctx.logger.error(err); - } - - return next(); -} - -module.exports = retrieveDomain; diff --git a/app/controllers/web/my-account/retrieve-domains.js b/app/controllers/web/my-account/retrieve-domains.js deleted file mode 100644 index f1521d015..000000000 --- a/app/controllers/web/my-account/retrieve-domains.js +++ /dev/null @@ -1,177 +0,0 @@ -const RE2 = require('re2'); -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); - -const config = require('#config'); -const { Domains, Aliases } = require('#models'); - -async function retrieveDomains(ctx, next) { - ctx.state.domains = []; - - if (!ctx.isAuthenticated()) return next(); - - const query = { - $or: [{ 'members.user': ctx.state.user._id }] - }; - if (ctx.state.user.group === 'admin') query.$or.push({ is_global: true }); - else - query.$or.push({ - is_global: true - // NOTE: if we uncomment this then DNS changes could impact global vanity domains - // has_mx_record: true, - // has_txt_record: true - }); - - // eslint-disable-next-line unicorn/no-array-callback-reference - ctx.state.domains = await Domains.find(query) - .populate( - 'members.user', - `id email ${config.passport.fields.displayName} ${config.userFields.isBanned}` - ) - .sort('name') // A-Z domains - .lean() - .exec(); - - ctx.state.domains = ctx.state.domains.map((domain) => { - domain.members = domain.members.filter( - (member) => - _.isObject(member.user) && !member.user[config.userFields.isBanned] - ); - return domain; - }); - - let domainAliases = await Aliases.find({ - domain: { $in: _.map(ctx.state.domains, '_id') } - }) - .populate('domain', 'name') - .populate( - 'user', - `id email ${config.passport.fields.displayName} ${config.userFields.isBanned}` - ) - .sort('name') - .lean() - .exec(); - - domainAliases = domainAliases.filter( - (alias) => _.isObject(alias.user) && !alias.user[config.userFields.isBanned] - ); - - const aliasesByDomain = _.groupBy( - domainAliases, - (alias) => alias.domain.name - ); - - ctx.state.domains = ctx.state.domains.map((domain) => { - // populate a `group` on the domain based off the user's association - let member = domain.members.find( - (member) => member.user.id === ctx.state.user.id - ); - - // for all global domains, if the user is not a member - // then add them as a member to the domain - if (!member && domain.is_global) { - member = { - user: { - _id: ctx.state.user._id, - id: ctx.state.user.id, - email: ctx.state.user.email - }, - group: 'user', - // store the assignment was virtual - is_virtual: true - }; - domain.members.push(member); - } - - const { group } = member; - - // populate an `aliases` Array on the domain based off user's aliases - const aliases = []; - - if (aliasesByDomain[domain.name]) - for (const alias of aliasesByDomain[domain.name]) { - if (group === 'admin' || alias.user.id === ctx.state.user.id) - aliases.push({ - ...alias, - // for each alias set a virtual group helper - // (if the user is an admin OR if the user is the owner of the alias) - group: - group === 'admin' || alias.user.id === ctx.state.user.id - ? 'admin' - : 'user' - }); - } - - // iterate over domain.members and add `alias_count` virtual property - // which counts across the aliases for the given member's user id - domain.members = domain.members.map((member) => ({ - ...member, - alias_count: aliasesByDomain[domain.name] - ? aliasesByDomain[domain.name].filter( - (alias) => alias.user.id === member.user.id - ).length - : 0 - })); - - return { - ...domain, - group, - aliases - }; - }); - - // - // search functionality (with RegExp support) - // - if (!ctx.pathWithoutLocale.endsWith('/aliases')) { - if (isSANB(ctx.query.name)) - ctx.state.domains = ctx.state.domains.filter((domain) => - new RE2(_.escapeRegExp(ctx.query.name)).test(domain.name) - ); - - if (isSANB(ctx.query.alias)) { - const aliasRegex = new RE2(_.escapeRegExp(ctx.query.alias)); - ctx.state.domains = ctx.state.domains.filter((domain) => - domain.aliases.some((alias) => aliasRegex.test(alias.name)) - ); - } - - if (isSANB(ctx.query.recipient)) { - const recipientRegex = new RE2(_.escapeRegExp(ctx.query.recipient)); - ctx.state.domains = ctx.state.domains.filter((domain) => - domain.aliases.some((alias) => - alias.recipients.some((recipient) => recipientRegex.test(recipient)) - ) - ); - } - } - - if (ctx.api) return next(); - - if ( - // as part of onboarding redirect users to create a new domain right away - // unless of course they already had created global vanity domain - ctx.state.domains.filter( - (domain) => - !domain.is_global || (domain.is_global && domain.aliases.length > 0) - ).length === 0 && - ctx.method === 'GET' && - ['/my-account', '/my-account/domains'].includes(ctx.pathWithoutLocale) - ) { - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t(`${ctx.state.emoji('wave')} Welcome!`), - text: ctx.translate('NO_DOMAINS_EXIST'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 5000, - position: 'top' - }); - return ctx.redirect('/my-account/domains/new'); - } - - return next(); -} - -module.exports = retrieveDomains; diff --git a/app/controllers/web/my-account/retrieve-invite.js b/app/controllers/web/my-account/retrieve-invite.js deleted file mode 100644 index 0f98693cb..000000000 --- a/app/controllers/web/my-account/retrieve-invite.js +++ /dev/null @@ -1,84 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); - -const { Domains } = require('#models'); - -async function retrieveInvite(ctx) { - if (!isSANB(ctx.params.domain_id)) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - const domain = await Domains.findOne({ - $or: [ - { - id: ctx.params.domain_id, - 'invites.email': ctx.state.user.email - }, - { - id: ctx.params.domain_id, - 'members.user': ctx.state.user._id - } - ] - }); - - if (!domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - // convert invitee to a member with the same group as invite had - const invite = domain.invites.find( - (invite) => invite.email === ctx.state.user.email - ); - - let group = 'user'; - - if (invite) { - ({ group } = invite); - domain.members.push({ - user: ctx.state.user._id, - group - }); - - // remove invitee from invites list - domain.invites = domain.invites.filter( - (invite) => invite.email !== ctx.state.user.email - ); - - // save domain - domain.locale = ctx.locale; - domain.skip_verification = true; - ctx.state.domain = await domain.save(); - } else { - const match = domain.members.find( - (member) => member._id.toString() === ctx.state.user.id - ); - group = match && match.group === 'admin' ? 'admin' : 'user'; - } - - // flash a message to the user telling them they've successfully accepted - const message = - group === 'admin' - ? ctx.translate('INVITE_ACCEPTED_ADMIN') - : ctx.translate('INVITE_ACCEPTED_USER'); - - // edge case if it was an API request to simply send a string in the body - if (ctx.api) { - ctx.body = message; - return; - } - - ctx.flash('success', message); - - // redirect user to either alias page (if user) or admin page (if admin) - const redirectTo = - group === 'admin' - ? ctx.state.l(`/my-account/domains/${domain.name}`) - : ctx.state.l(`/my-account/domains/${domain.name}/aliases`); - - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; -} - -module.exports = retrieveInvite; diff --git a/app/controllers/web/my-account/retrieve-profile.js b/app/controllers/web/my-account/retrieve-profile.js deleted file mode 100644 index 0f03757b4..000000000 --- a/app/controllers/web/my-account/retrieve-profile.js +++ /dev/null @@ -1,14 +0,0 @@ -const countryList = require('country-list'); - -// country list with USA at the top -const USA = 'United States of America'; -const countries = countryList.getNames().sort(); -countries.splice(countries.indexOf(USA), 1); -countries.unshift(USA); - -function retrieveProfile(ctx) { - ctx.state.countries = countries; - return ctx.render('my-account/profile'); -} - -module.exports = retrieveProfile; diff --git a/app/controllers/web/my-account/retrieve-receipt.js b/app/controllers/web/my-account/retrieve-receipt.js deleted file mode 100644 index c01da4e57..000000000 --- a/app/controllers/web/my-account/retrieve-receipt.js +++ /dev/null @@ -1,86 +0,0 @@ -const path = require('path'); - -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); -const pify = require('pify'); -const pug = require('pug'); -const webResourceInliner = require('web-resource-inliner'); -const wkhtmltopdf = require('wkhtmltopdf'); - -const inline = pify(webResourceInliner.html); - -const config = require('#config'); -const { Payments } = require('#models'); - -async function retrieveReceipt(ctx) { - try { - if (!isSANB(ctx.params.reference)) - throw ctx.throw( - Boom.badRequest(ctx.translateError('PAYMENT_REFERENCE_INVALID')) - ); - - const isPDF = ctx.params.reference.endsWith('.pdf'); - const isHTML = ctx.params.reference.endsWith('.html'); - const reference = ctx.params.reference - .replace('.pdf', '') - .replace('.html', ''); - - ctx.state.payment = await Payments.findOne({ - reference: reference.toUpperCase(), // normalize - user: ctx.state.user._id - }); - - if (!ctx.state.payment) - throw ctx.throw( - Boom.badRequest(ctx.translateError('PAYMENT_REFERENCE_INVALID')) - ); - - if (isPDF) { - const html = pug.renderFile( - path.join(config.views.root, 'my-account', 'billing', 'pdf.pug'), - // make flash a noop so we don't interfere with messages/session - { ...ctx.state, flash() {} } - ); - - // - // workaround because of these bugs with wkhtmltopdf and HTTPS - // - // - // - // - const inlinedHTML = await inline({ - fileContent: html, - images: true, - svgs: true, - scripts: false, - links: true, - relativeTo: config.buildDir - }); - ctx.body = wkhtmltopdf(inlinedHTML, { - debug: config.env !== 'production', - pageSize: 'letter', - background: true, - 'image-dpi': 300, - 'print-media-type': false, - 'enable-javascript': false, - 'enable-internal-links': true - }); - return; - } - - if (isHTML) return ctx.render('my-account/billing/pdf'); - - ctx.state.breadcrumbHeaderCentered = true; - ctx.state.breadcrumbs.pop(); - ctx.state.breadcrumbs.push({ - name: ctx.state.payment.reference, - header: ctx.translate('RECEIPT') - }); - - return ctx.render('my-account/billing/receipt'); - } catch (err) { - ctx.throw(err); - } -} - -module.exports = retrieveReceipt; diff --git a/app/controllers/web/my-account/sorted-domains.js b/app/controllers/web/my-account/sorted-domains.js deleted file mode 100644 index d1f9b8dc1..000000000 --- a/app/controllers/web/my-account/sorted-domains.js +++ /dev/null @@ -1,27 +0,0 @@ -const _ = require('lodash'); -const isFQDN = require('is-fqdn'); -const isSANB = require('is-string-and-not-blank'); -const { isIP } = require('validator'); - -function sortedDomains(ctx, next) { - ctx.state.sortedDomains = _.clone(ctx.state.domains); - ctx.state.sortedDomains = ctx.state.sortedDomains.filter( - (domain) => !domain.is_global - ); - if ( - isSANB(ctx.query.domain) && - (isFQDN(ctx.query.domain) || isIP(ctx.query.domain)) && - ctx.state.sortedDomains.some((domain) => domain.name === ctx.query.domain) - ) - ctx.state.sortedDomains = _.sortBy( - ctx.state.sortedDomains.map((domain, i) => ({ - ...domain, - _key: domain.name === ctx.query.domain ? 0 : i + 1 - })), - '_key' - ); - - return next(); -} - -module.exports = sortedDomains; diff --git a/app/controllers/web/my-account/update-alias.js b/app/controllers/web/my-account/update-alias.js deleted file mode 100644 index 1d6a09c5d..000000000 --- a/app/controllers/web/my-account/update-alias.js +++ /dev/null @@ -1,42 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); - -const toObject = require('#helpers/to-object'); -const { Users, Domains, Aliases } = require('#models'); - -async function updateAlias(ctx, next) { - ctx.state.alias = await Aliases.findById(ctx.state.alias._id); - ctx.state.alias = _.extend(ctx.state.alias, ctx.state.body); - try { - ctx.state.alias.locale = ctx.locale; - ctx.state.alias = await ctx.state.alias.save(); - if (ctx.api) { - ctx.state.alias = toObject(Aliases, ctx.state.alias); - ctx.state.alias.user = toObject(Users, ctx.state.user); - ctx.state.alias.domain = toObject(Domains, ctx.state.domain); - ctx.state.alias.domain.members = ctx.state.domain.members; - ctx.state.alias.domain.invites = ctx.state.domain.invites; - return next(); - } - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - const redirectTo = ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}/aliases` - ); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; - } catch (err) { - ctx.logger.error(err); - ctx.throw(Boom.badRequest(err.message)); - } -} - -module.exports = updateAlias; diff --git a/app/controllers/web/my-account/update-domain.js b/app/controllers/web/my-account/update-domain.js deleted file mode 100644 index 7864c97ee..000000000 --- a/app/controllers/web/my-account/update-domain.js +++ /dev/null @@ -1,108 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); -const { boolean } = require('boolean'); -const { isPort } = require('validator'); - -const { Domains } = require('#models'); - -// eslint-disable-next-line complexity -async function updateDomain(ctx, next) { - ctx.state.domain = await Domains.findById(ctx.state.domain._id); - if (!ctx.state.domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - // Custom SMTP Port Forwarding - if (isSANB(ctx.request.body.smtp_port)) { - if (isPort(ctx.request.body.smtp_port)) - ctx.state.domain.smtp_port = ctx.request.body.smtp_port; - else return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_PORT'))); - } - - // Boolean settings for spam and requiring recipient verification - if (ctx.api) { - // require paid plan (note that the API middleware already does this) - for (const bool of [ - 'has_adult_content_protection', - 'has_phishing_protection', - 'has_executable_protection', - 'has_virus_protection', - 'has_recipient_verification' - ]) { - if (_.isBoolean(ctx.request.body[bool]) || isSANB(ctx.request.body[bool])) - ctx.state.domain[bool] = boolean(ctx.request.body[bool]); - } - } else if (ctx.request.body._section === 'spam_scanner_settings') { - // require paid plan - if (ctx.state.domain.plan === 'free') - return ctx.throw( - Boom.badRequest(ctx.translateError('PLAN_UPGRADE_REQUIRED')) - ); - for (const bool of [ - 'has_adult_content_protection', - 'has_phishing_protection', - 'has_executable_protection', - 'has_virus_protection' - ]) { - ctx.state.domain[bool] = boolean(ctx.request.body[bool]); - } - } else if (ctx.request.body._section === 'recipient_verification') { - // require paid plan - if (ctx.state.domain.plan === 'free') - return ctx.throw( - Boom.badRequest(ctx.translateError('PLAN_UPGRADE_REQUIRED')) - ); - ctx.state.domain.has_recipient_verification = boolean( - ctx.request.body.has_recipient_verification - ); - } - - // custom verification IFF allowed - if ( - !ctx.api && - ctx.request.body._section === 'custom_verification_template' - ) { - // require paid plan - if ( - ctx.state.domain.plan === 'free' || - !ctx.state.domain.has_custom_verification - ) - return ctx.throw( - Boom.badRequest(ctx.translateError('PLAN_UPGRADE_REQUIRED')) - ); - for (const prop of ['name', 'email', 'subject', 'redirect', 'html']) { - if (_.isString(ctx.request.body[`custom_verification_${prop}`])) { - ctx.state.domain.custom_verification[prop] = isSANB( - ctx.request.body[`custom_verification_${prop}`] - ) - ? ctx.request.body[`custom_verification_${prop}`] - : ''; - } - } - } - - // TODO: eventually let users customize the text portion (for now we use html-to-text) - - ctx.state.domain.locale = ctx.locale; - ctx.state.domain.skip_verification = true; - ctx.state.domain = await ctx.state.domain.save(); - - if (ctx.api) return next(); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = updateDomain; diff --git a/app/controllers/web/my-account/update-member.js b/app/controllers/web/my-account/update-member.js deleted file mode 100644 index 15f13df1e..000000000 --- a/app/controllers/web/my-account/update-member.js +++ /dev/null @@ -1,61 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); - -const { Domains } = require('#models'); - -async function updateMember(ctx, next) { - // ctx.params.member_id - if (!isSANB(ctx.params.member_id)) - return ctx.throw(Boom.notFound(ctx.translateError('INVALID_USER'))); - - // ctx.request.body.group - if ( - !isSANB(ctx.request.body.group) || - !['admin', 'user'].includes(ctx.request.body.group) - ) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_GROUP'))); - - ctx.state.domain = await Domains.findById(ctx.state.domain._id); - if (!ctx.state.domain) - return ctx.throw( - Boom.notFound(ctx.translateError('DOMAIN_DOES_NOT_EXIST')) - ); - - const match = ctx.state.domain.members.find( - (member) => member.user.toString() === ctx.params.member_id - ); - - if (!match) - return ctx.throw(Boom.notFound(ctx.translateError('INVALID_USER'))); - - // swap the user group based off ctx.request.body.group - // - ctx.state.domain.members = ctx.state.domain.members.map((member) => ({ - user: member.user, - group: - member.user.toString() === ctx.params.member_id - ? ctx.request.body.group - : member.group - })); - - ctx.state.domain.locale = ctx.locale; - ctx.state.domain.client = ctx.client; - ctx.state.domain = await ctx.state.domain.save(); - - if (ctx.api) return next(); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = updateMember; diff --git a/app/controllers/web/my-account/update-profile.js b/app/controllers/web/my-account/update-profile.js deleted file mode 100644 index c8adfa2ee..000000000 --- a/app/controllers/web/my-account/update-profile.js +++ /dev/null @@ -1,197 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const cryptoRandomString = require('crypto-random-string'); -const dayjs = require('dayjs-with-plugins'); -const humanize = require('humanize-string'); -const isSANB = require('is-string-and-not-blank'); -const { isEmail } = require('validator'); - -const config = require('#config'); -const emailHelper = require('#helpers/email'); -const { Users } = require('#models'); - -// eslint-disable-next-line complexity -async function updateProfile(ctx) { - const { body } = ctx.request; - const hasSetPassword = ctx.state.user[config.userFields.hasSetPassword]; - - const requiredFields = ['password', 'confirm_password']; - - if (hasSetPassword) requiredFields.push('old_password'); - - if (body.change_password === 'true') { - for (const prop of requiredFields) { - if (!isSANB(body[prop])) - throw Boom.badRequest( - ctx.translateError('INVALID_STRING', ctx.request.t(humanize(prop))) - ); - } - - if (body.password !== body.confirm_password) - throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD_CONFIRM')); - - if (hasSetPassword) - await ctx.state.user.changePassword(body.old_password, body.password); - else { - await ctx.state.user.setPassword(body.password); - ctx.state.user[config.userFields.hasSetPassword] = true; - } - - ctx.state.user[config.userFields.resetToken] = null; - ctx.state.user[config.userFields.resetTokenExpiresAt] = null; - } else { - // - // personal information - // - if (_.isString(body[config.passport.fields.givenName])) - ctx.state.user[config.passport.fields.givenName] = - body[config.passport.fields.givenName]; - if (_.isString(body[config.passport.fields.familyName])) - ctx.state.user[config.passport.fields.familyName] = - body[config.passport.fields.familyName]; - if ( - _.isString(body[config.userFields.defaultDomain]) && - (body[config.userFields.defaultDomain] === 'None' || - ctx.state.domains.some( - (d) => d.id === body[config.userFields.defaultDomain] - )) - ) - ctx.state.user[config.userFields.defaultDomain] = - body[config.userFields.defaultDomain] === 'None' - ? undefined - : body[config.userFields.defaultDomain]; - - ctx.logger.debug('updated user', { - [config.userFields.defaultDomain]: - ctx.state.user[config.userFields.defaultDomain], - body: body[config.userFields.defaultDomain] - }); - - // - // company information - // - for (const prop of [ - config.userFields.companyName, - config.userFields.addressLine1, - config.userFields.addressLine2, - config.userFields.addressCity, - config.userFields.addressState, - config.userFields.addressZip, - config.userFields.addressCountry, - config.userFields.companyVAT - ]) { - if (_.isString(body[prop])) ctx.state.user[prop] = body[prop]; - } - } - - // check if we need to update the email and send an email confirmation - const hasNewEmail = - isSANB(body.email) && - ctx.state.user[config.passportLocalMongoose.usernameField] !== body.email; - - // confirm user supplied email is different than current email - if (hasNewEmail) { - // ensure that a password has been set first - // (this is a safety guard even though the email is read only if so) - if (!ctx.state.user[config.userFields.hasSetPassword]) - return ctx.throw( - Boom.badRequest(ctx.translateError('PASSWORD_REQUIRED')) - ); - - // validate it (so it doesn't have to use mongoose for this) - if (!isEmail(body.email)) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_EMAIL'))); - - // if we've already sent a change email request in the past half hour - if ( - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] && - ctx.state.user[config.userFields.changeEmailToken] && - dayjs( - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] - ).isAfter( - dayjs().subtract(config.changeEmailTokenTimeoutMs, 'milliseconds') - ) - ) - throw Boom.badRequest( - ctx.translateError( - 'EMAIL_CHANGE_LIMIT', - dayjs.duration(config.changeEmailLimitMs, 'milliseconds').minutes(), - dayjs( - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] - ).fromNow() - ) - ); - - // short-circuit if email already exists - const query = { email: body.email }; - const user = await Users.findOne(query); - if (user) - throw Boom.badRequest( - ctx.translateError('EMAIL_CHANGE_ALREADY_EXISTS', body.email) - ); - - // set the reset token and expiry - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] = dayjs() - .add(config.changeEmailTokenTimeoutMs, 'milliseconds') - .toDate(); - ctx.state.user[config.userFields.changeEmailToken] = - await cryptoRandomString.async({ - length: 32 - }); - ctx.state.user[config.userFields.changeEmailNewAddress] = body.email; - } - - // save the user - ctx.state.user = await ctx.state.user.save(); - - // send the email - if (hasNewEmail) { - try { - await emailHelper({ - template: 'change-email', - message: { - to: body.email - }, - locals: { - user: _.pick(ctx.state.user, [ - config.userFields.changeEmailTokenExpiresAt, - config.userFields.changeEmailNewAddress, - config.passportLocalMongoose.usernameField - ]), - link: `${config.urls.web}/my-account/change-email/${ - ctx.state.user[config.userFields.changeEmailToken] - }` - } - }); - } catch (err) { - ctx.logger.fatal(err); - // reset if there was an error - try { - ctx.state.user[config.userFields.changeEmailToken] = null; - ctx.state.user[config.userFields.changeEmailTokenExpiresAt] = null; - ctx.state.user[config.userFields.changeEmailNewAddress] = null; - ctx.state.user = await ctx.state.user.save(); - } catch (err) { - ctx.logger.error(err); - } - - throw Boom.badRequest(ctx.translateError('EMAIL_FAILED_TO_SEND')); - } - } - - if (!ctx.api) - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate(hasNewEmail ? 'EMAIL_CHANGE_SENT' : 'REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect('back'); - else ctx.body = { reloadPage: true }; -} - -module.exports = updateProfile; diff --git a/app/controllers/web/my-account/validate-alias.js b/app/controllers/web/my-account/validate-alias.js deleted file mode 100644 index e74f215e3..000000000 --- a/app/controllers/web/my-account/validate-alias.js +++ /dev/null @@ -1,86 +0,0 @@ -const _ = require('lodash'); -const isSANB = require('is-string-and-not-blank'); -const slug = require('speakingurl'); -const splitLines = require('split-lines'); -const striptags = require('striptags'); -const { boolean } = require('boolean'); - -function validateAlias(ctx, next) { - const body = _.pick(ctx.request.body, [ - 'name', - 'description', - 'labels', - 'is_enabled', - 'recipients' - ]); - - if (!isSANB(body.name)) delete body.name; - - body.description = isSANB(body.description) - ? striptags(body.description) - : ''; - - if (isSANB(body.labels)) - body.labels = _.compact( - _.uniq( - _.map( - splitLines(body.labels).join(' ').split(',').join(' ').split(' '), - (label) => slug(label.trim()) - ) - ) - ); - else if (_.isArray(body.labels)) - body.labels = _.compact( - _.uniq( - _.map(body.labels, (label) => - isSANB(label) ? slug(label.trim()) : null - ) - ) - ); - else body.labels = []; - - // has_recipient_verification (defaults to domain's setting if not set) - if (ctx.api) { - if ( - _.isBoolean(ctx.request.body.has_recipient_verification) || - isSANB(ctx.request.body.has_recipient_verification) - ) - body.has_recipient_verification = boolean( - ctx.request.body.has_recipient_verification - ); - else if (!ctx.state.alias) - body.has_recipient_verification = - ctx.state.domain.has_recipient_verification; - } else { - body.has_recipient_verification = boolean( - ctx.request.body.has_recipient_verification - ); - } - - body.is_enabled = boolean(body.is_enabled); - - if (isSANB(body.recipients)) - body.recipients = _.compact( - _.uniq( - _.map( - splitLines(body.recipients).join(' ').split(',').join(' ').split(' '), - (recipient) => recipient.trim() - ) - ) - ); - else if (_.isArray(body.recipients)) - body.recipients = _.compact( - _.uniq( - _.map(body.recipients, (recipient) => - isSANB(recipient) ? recipient.trim() : null - ) - ) - ); - else body.recipients = []; - - ctx.state.body = body; - - return next(); -} - -module.exports = validateAlias; diff --git a/app/controllers/web/my-account/validate-domain.js b/app/controllers/web/my-account/validate-domain.js deleted file mode 100644 index 325f4fd9a..000000000 --- a/app/controllers/web/my-account/validate-domain.js +++ /dev/null @@ -1,113 +0,0 @@ -const Boom = require('@hapi/boom'); -const _ = require('lodash'); -const isFQDN = require('is-fqdn'); -const isSANB = require('is-string-and-not-blank'); -const splitLines = require('split-lines'); -const { boolean } = require('boolean'); -const { isIP } = require('validator'); - -// eslint-disable-next-line complexity -async function validateDomain(ctx, next) { - if ( - !isSANB(ctx.request.body.domain) || - (!isFQDN(ctx.request.body.domain) && !isIP(ctx.request.body.domain)) - ) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_DOMAIN'))); - - // trim and convert to lowercase the domain name - ctx.request.body.domain = ctx.request.body.domain.trim().toLowerCase(); - - const match = ctx.state.domains.find( - (domain) => domain.name === ctx.request.body.domain - ); - - if (match) - return ctx.throw( - Boom.badRequest(ctx.translateError('DOMAIN_ALREADY_EXISTS')) - ); - - if (isSANB(ctx.request.body.plan)) { - if ( - !['free', 'enhanced_protection', 'team'].includes(ctx.request.body.plan) - ) - return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_PLAN'))); - } else { - ctx.request.body.plan = ctx.state.user.plan || 'free'; - } - - // check if we're creating a default catchall - ctx.state.recipients = [ctx.state.user.email]; - - if (_.isBoolean(ctx.request.body.catchall) && !ctx.request.body.catchall) - ctx.state.recipients.pop(); - else if (isSANB(ctx.request.body.catchall)) { - const rcpts = _.compact( - _.uniq( - _.map( - splitLines(ctx.request.body.catchall) - .join(' ') - .split(',') - .join(' ') - .split(' '), - (recipient) => recipient.trim() - ) - ) - ); - for (const rcpt of rcpts) { - ctx.state.recipients.push(rcpt); - } - } - - ctx.state.redirectTo = ctx.state.l( - `/my-account/domains/${ctx.request.body.domain}` - ); - - // if the user was not on a valid plan then redirect them to billing post creation - if (isSANB(ctx.request.body.plan)) { - switch (ctx.request.body.plan) { - case 'enhanced_protection': { - if (!['enhanced_protection', 'team'].includes(ctx.state.user.plan)) { - ctx.request.body.plan = 'free'; - ctx.state.redirectTo = ctx.state.l( - `/my-account/domains/${ctx.request.body.domain}/billing?plan=enhanced_protection` - ); - } - - break; - } - - case 'team': { - if (ctx.state.user.plan !== 'team') { - ctx.request.body.plan = - ctx.state.user.plan === 'enhanced_protection' - ? 'enhanced_protection' - : 'free'; - ctx.state.redirectTo = ctx.state.l( - `/my-account/domains/${ctx.request.body.domain}/billing?plan=team` - ); - } - - break; - } - - // No default - } - } - - // Boolean settings for spam and requiring recipient verification - ctx.state.optionalBooleans = {}; - for (const bool of [ - 'has_adult_content_protection', - 'has_phishing_protection', - 'has_executable_protection', - 'has_virus_protection', - 'has_recipient_verification' - ]) { - if (_.isBoolean(ctx.request.body[bool]) || isSANB(ctx.request.body[bool])) - ctx.state.optionalBooleans[bool] = boolean(ctx.request.body[bool]); - } - - return next(); -} - -module.exports = validateDomain; diff --git a/app/controllers/web/my-account/verify-records.js b/app/controllers/web/my-account/verify-records.js deleted file mode 100644 index 2290741ce..000000000 --- a/app/controllers/web/my-account/verify-records.js +++ /dev/null @@ -1,72 +0,0 @@ -const Boom = require('@hapi/boom'); -const ForwardEmail = require('forward-email'); - -const config = require('#config'); -const logger = require('#helpers/logger'); -const { Domains } = require('#models'); - -const app = new ForwardEmail({ - logger, - recordPrefix: config.recordPrefix, - srs: { secret: 'null' }, - redis: false -}); - -async function verifyRecords(ctx) { - try { - // reset redis cache for web and smtp - if (ctx.client) - await Promise.all( - ['A', 'MX', 'TXT'].map(async (type) => { - try { - await app.resolver(ctx.state.domain.name, type, true, ctx.client); - } catch (err) { - ctx.logger.fatal(err); - } - }) - ); - - // check mx and txt - await Domains.verifyRecords(ctx.state.domain._id, ctx.locale, ctx.client); - - const text = ctx.translate('DOMAIN_IS_VERIFIED'); - - if (ctx.api) { - ctx.body = text; - return; - } - - // if everything OK then success - ctx.flash('custom', { - title: ctx.request.t('Success'), - text, - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - const redirectTo = ctx.state.l( - `/my-account/domains/${ctx.state.domain.name}` - ); - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; - } catch (err) { - ctx.logger.warn(err); - - if (Array.isArray(err.errors)) { - if (ctx.api) { - err.message = err.errors.map((e) => e.message); - } else { - err.message = `
        ${err.errors - .map((e) => `
      • ${e && e.message ? e.message : e}
      • `) - .join('')}
      `; - } - } - - ctx.throw(Boom.badRequest(err.message)); - } -} - -module.exports = verifyRecords; diff --git a/app/controllers/web/otp/disable.js b/app/controllers/web/otp/disable.js deleted file mode 100644 index 9b406403e..000000000 --- a/app/controllers/web/otp/disable.js +++ /dev/null @@ -1,41 +0,0 @@ -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); - -const config = require('#config'); - -async function disable(ctx) { - const { body } = ctx.request; - - const redirectTo = ctx.state.l('/my-account/security'); - - if (!ctx.state.user[config.passport.fields.otpEnabled]) - throw Boom.badRequest(ctx.translateError('TWO_FACTOR_REQUIRED')); - - if (ctx.state.user[config.userFields.hasSetPassword]) { - if (!isSANB(body.password)) - throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); - - const { user } = await ctx.state.user.authenticate(body.password); - if (!user) throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); - } - - ctx.state.user[config.passport.fields.otpEnabled] = false; - ctx.state.user[config.passport.fields.otpToken] = null; - ctx.state.user[config.userFields.otpRecoveryKeys] = null; - await ctx.state.user.save(); - - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - - if (ctx.accepts('html')) ctx.redirect(redirectTo); - else ctx.body = { redirectTo }; -} - -module.exports = disable; diff --git a/app/controllers/web/otp/index.js b/app/controllers/web/otp/index.js deleted file mode 100644 index 8db3cabc0..000000000 --- a/app/controllers/web/otp/index.js +++ /dev/null @@ -1,6 +0,0 @@ -const disable = require('./disable'); -const recovery = require('./recovery'); -const setup = require('./setup'); -const keys = require('./keys'); - -module.exports = { disable, recovery, keys, setup }; diff --git a/app/controllers/web/otp/keys.js b/app/controllers/web/otp/keys.js deleted file mode 100644 index 8e902d255..000000000 --- a/app/controllers/web/otp/keys.js +++ /dev/null @@ -1,7 +0,0 @@ -async function keys(ctx) { - // this is like a migration, it will automatically add token + keys if needed - await ctx.state.user.save(); - return ctx.render('otp/setup'); -} - -module.exports = keys; diff --git a/app/controllers/web/otp/recovery.js b/app/controllers/web/otp/recovery.js deleted file mode 100644 index cab9ba04e..000000000 --- a/app/controllers/web/otp/recovery.js +++ /dev/null @@ -1,36 +0,0 @@ -const config = require('#config'); -const sendVerificationEmail = require('#helpers/send-verification-email'); - -async function recovery(ctx) { - const redirectTo = ctx.state.l(config.verifyRoute); - - ctx.state.redirectTo = redirectTo; - - ctx.state.user[config.userFields.pendingRecovery] = true; - await ctx.state.user.save(); - - try { - ctx.state.user = await sendVerificationEmail(ctx); - } catch (err) { - // wrap with try/catch to prevent redirect looping - // (even though the koa redirect loop package will help here) - if (!err.isBoom) return ctx.throw(err); - ctx.logger.warn(err); - if (ctx.accepts('html')) { - ctx.flash('warning', err.message); - ctx.redirect(ctx.state.l(config.loginRoute)); - } else { - ctx.body = { message: err.message }; - } - - return; - } - - if (ctx.accepts('html')) { - ctx.redirect(redirectTo); - } else { - ctx.body = { redirectTo }; - } -} - -module.exports = recovery; diff --git a/app/controllers/web/otp/setup.js b/app/controllers/web/otp/setup.js deleted file mode 100644 index bcc087edc..000000000 --- a/app/controllers/web/otp/setup.js +++ /dev/null @@ -1,86 +0,0 @@ -const process = require('process'); - -const Boom = require('@hapi/boom'); -const isSANB = require('is-string-and-not-blank'); -const qrcode = require('qrcode'); -const { authenticator } = require('otplib'); - -const config = require('#config'); - -const options = { width: 500, margin: 0 }; - -// allow last and current totp passcode -authenticator.options = { - window: 1 -}; - -async function setup(ctx) { - const { body } = ctx.request; - - if (ctx.method === 'DELETE') { - ctx.state.user[config.passport.fields.otpEnabled] = false; - await ctx.state.user.save(); - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - ctx.redirect(ctx.state.l('/my-account/security')); - return; - } - - if (isSANB(body.token)) { - const { token } = ctx.request.body; - const secret = ctx.state.user[config.passport.fields.otpToken]; - const isValid = authenticator.checkDelta(token, secret); - - // delta should be 0 for current, or -1 for last token - if (isValid !== 0 && isValid !== -1) { - ctx.flash('error', ctx.translate('INVALID_OTP_PASSCODE')); - ctx.state.otpTokenURI = authenticator.keyuri( - ctx.state.user.email, - process.env.WEB_HOST, - ctx.state.user[config.passport.fields.otpToken] - ); - ctx.state.qrcode = await qrcode.toDataURL(ctx.state.otpTokenURI, options); - return ctx.render('otp/enable'); - } - - ctx.state.user[config.passport.fields.otpEnabled] = true; - await ctx.state.user.save(); - ctx.session.otp = 'totp-setup'; - ctx.flash('custom', { - title: ctx.request.t('Success'), - text: ctx.translate('REQUEST_OK'), - type: 'success', - toast: true, - showConfirmButton: false, - timer: 3000, - position: 'top' - }); - ctx.redirect(ctx.state.l('/my-account/security')); - return; - } - - if (ctx.state.user[config.userFields.hasSetPassword]) { - if (!isSANB(body.password)) - throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); - - const { user } = await ctx.state.user.authenticate(body.password); - if (!user) throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); - } - - ctx.state.otpTokenURI = authenticator.keyuri( - ctx.state.user.email, - process.env.WEB_HOST, - ctx.state.user[config.passport.fields.otpToken] - ); - ctx.state.qrcode = await qrcode.toDataURL(ctx.state.otpTokenURI, options); - return ctx.render('otp/enable'); -} - -module.exports = setup; diff --git a/app/views/admin/domains/_table.pug b/app/views/admin/domains/_table.pug deleted file mode 100644 index 955eb92ad..000000000 --- a/app/views/admin/domains/_table.pug +++ /dev/null @@ -1,72 +0,0 @@ -include ../../_sort-header -include ../../_pagination - -.table-responsive - table.table.table-hover.table-bordered - thead.thead-dark - tr - th(scope="col") - +sortHeader('name', 'Name', '#table-domains') - th(scope="col") - +sortHeader('is_global', 'Global', '#table-domains') - th(scope="col") - +sortHeader('plan', 'Plan', '#table-domains') - th(scope="col") - +sortHeader('max_recipients_per_alias', 'Max Recipients', '#table-domains') - th(scope="col") - +sortHeader('created_at', 'Created', '#table-domains') - th.text-center.align-middle(scope="col")= t("Actions") - tbody - if domains.length === 0 - tr - td.alert.alert-info(colspan="5") - = t("No domains exist for that search.") - else - each domain in domains - tr - td.align-middle - .markdown-body.ml-0.mr-0: code= domain.name - td.align-middle.text-center - if domain.is_global - i.fa.fa-fw.fa-check.fa-2x.text-success - td.align-middle.text-center - = t(titleize(humanize(domain.plan))) - td.align-middle.text-center - form.ajax-form.confirm-prompt.d-inline-block( - action=l(`/admin/domains/${domain.id}`), - method="POST", - autocomplete="off" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - input(type="hidden", name="_method", value="PUT") - .input-group - input.form-control( - type="number", - min=0, - max=1000, - step=10, - name="max_recipients_per_alias", - value=domain.max_recipients_per_alias - ) - .input-group-append - button.btn.btn-sm.btn-dark( - type="submit", - data-toggle="tooltip", - data-title=t("Update") - ): i.fa.fa-fw.fa-save - td.align-middle.text-center - = dayjs(domain.created_at).format("M/D/YY h:mm A") - td.align-middle - form.ajax-form.confirm-prompt( - action=l(`/admin/domains/${domain.id}`), - method="POST", - autocomplete="off" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - input(type="hidden", name="_method", value="DELETE") - button.btn.btn-danger( - type="submit", - data-toggle="tooltip", - data-title=t("Remove") - ): i.fa.fa-fw.fa-remove -+paginate('#table-domains') diff --git a/app/views/admin/domains/index.pug b/app/views/admin/domains/index.pug deleted file mode 100644 index b8d3f9eb8..000000000 --- a/app/views/admin/domains/index.pug +++ /dev/null @@ -1,25 +0,0 @@ -extends ../../layout - -block body - .container-fluid.py-3 - .row.mt-1 - .col - include ../../_breadcrumbs - form.ajax-form.table-ajax-form( - action=ctx.path, - method="GET", - data-table="#table-domains", - data-search-params="name" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - .input-group.mb-3 - input#input-keyword-search.form-control( - type="text", - value=ctx.query.name ? ctx.query.name : "", - name="name", - placeholder=t("Search by domain name") - ) - .input-group-append - button.btn.btn-success(type="submit")= t("Search") - #table-domains - include ./_table diff --git a/app/views/admin/users/_table.pug b/app/views/admin/users/_table.pug deleted file mode 100644 index b5b30dce9..000000000 --- a/app/views/admin/users/_table.pug +++ /dev/null @@ -1,73 +0,0 @@ -include ../../_sort-header -include ../../_pagination - -.table-responsive - table.table.table-hover.table-bordered - thead.thead-dark - tr - th(scope="col") - +sortHeader(config.passport.fields.givenName, 'First Name', '#table-users') - th(scope="col") - +sortHeader(config.passport.fields.familyName, 'Last Name', '#table-users') - th(scope="col") - +sortHeader('email', null, '#table-users') - th(scope="col") - +sortHeader('group', null, '#table-users') - th(scope="col") - +sortHeader('created_at', 'Created', '#table-users') - th(scope="col") - +sortHeader('updated_at', 'Updated', '#table-users') - th(scope="col") - +sortHeader('last_login_at', 'Last Login', '#table-users') - if ctx.passport && ctx.passport.config.providers.otp - th(scope="col") - +sortHeader(config.passport.fields.otpEnabled, 'OTP Enabled', '#table-users') - th.text-center.align-middle(scope="col")= t("Actions") - tbody - if users.length === 0 - td.alert.alert-info( - colspan=ctx.passport && ctx.passport.config.providers.otp ? '8' : '7' - )= t("No users exist for that search.") - else - each user in users - tr - td.align-middle= user[config.passport.fields.givenName] - td.align-middle= user[config.passport.fields.familyName] - td.align-middle: a(href=`mailto:${user.email}`, target="_blank")= user.email - td.align-middle= titleize(user.group) - td.align-middle= dayjs(user.created_at).format("M/D/YY h:mm A") - td.align-middle= dayjs(user.updated_at).format("M/D/YY h:mm A") - td.align-middle= dayjs(user.last_login_at).format("M/D/YY h:mm A") - if ctx.passport && ctx.passport.config.providers.otp - td.align-middle= user[config.passport.fields.otpEnabled] - td.align-middle - .btn-group(role="group", aria-label=t("Actions")) - a.btn.btn-secondary( - href=l(`/admin/users/${user.id}`), - data-toggle="tooltip", - data-title=t("Edit") - ): i.fa.fa-fw.fa-edit - form.ajax-form.confirm-prompt.btn-group( - action=l(`/admin/users/${user.id}/login`), - method="POST", - autocomplete="off" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-secondary( - type="submit", - data-toggle="tooltip", - data-title=t("Log in as user") - ): i.fa.fa-fw.fa-user-secret - form.ajax-form.confirm-prompt.btn-group( - action=l(`/admin/users/${user.id}`), - method="POST", - autocomplete="off" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - input(type="hidden", name="_method", value="DELETE") - button.btn.btn-danger( - type="submit", - data-toggle="tooltip", - data-title=t("Remove") - ): i.fa.fa-fw.fa-remove -+paginate('#table-users') diff --git a/app/views/admin/users/index.pug b/app/views/admin/users/index.pug deleted file mode 100644 index a178b064b..000000000 --- a/app/views/admin/users/index.pug +++ /dev/null @@ -1,25 +0,0 @@ -extends ../../layout - -block body - .container-fluid.py-3 - .row.mt-1 - .col - include ../../_breadcrumbs - form.ajax-form.table-ajax-form( - action=ctx.path, - method="GET", - data-table="#table-users", - data-search-params="q" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - .input-group.mb-3 - input#input-keyword-search.form-control( - type="text", - value=ctx.query.q ? ctx.query.q : "", - name="q", - placeholder="Search by first name, last name, or email" - ) - .input-group-append - button.btn.btn-success(type="submit")= t("Search") - #table-users - include ./_table diff --git a/app/views/admin/users/retrieve.pug b/app/views/admin/users/retrieve.pug deleted file mode 100644 index df9e59c69..000000000 --- a/app/views/admin/users/retrieve.pug +++ /dev/null @@ -1,55 +0,0 @@ -extends ../../layout - -block body - .container-fluid.py-3 - .row.mt-1 - .col - include ../../_breadcrumbs - form.ajax-form.confirm-prompt(action=ctx.path, method="POST") - input(type="hidden", name="_method", value="PUT") - input(type="hidden", name="_csrf", value=ctx.csrf) - .card.border-dark - h4.card-header.bg-dark.text-white= result.id - .card-body - .form-group.floating-label - input#input-first-name.form-control( - type="text", - name=config.passport.fields.givenName, - value=result[config.passport.fields.givenName], - placeholder=t("First name") - ) - label(for="input-first-name")= t("First name") - .form-group.floating-label - input#input-last-name.form-control( - type="text", - name=config.passport.fields.familyName, - value=result[config.passport.fields.familyName], - placeholder=t("Last name") - ) - label(for="input-last-name")= t("Last name") - .form-group.floating-label - input#input-email.form-control( - type="email", - required, - name="email", - value=result.email, - placeholder="name@example.com" - ) - label(for="input-email")= t("Email address") - .form-group.floating-label - select#input-group.form-control(name="group", required) - option(value="user", selected=result.group === "user") User - option(value="admin", selected=result.group === "admin") Admin - label(for="input-group")= t("Group") - if ctx.passport && ctx.passport.config.providers.otp && result[config.passport.fields.otpEnabled] - .form-check - input#otp-enabled( - type="checkbox", - name=config.passport.fields.otpEnabled, - value="true", - checked - ) - label(for="input-otp-enabled")= t("OTP Enabled") - .card-footer.text-right - button.btn.btn-secondary(type="reset")= t("Reset") - button.btn.btn-primary(type="submit")= t("Save") diff --git a/app/views/guides/port-25-blocked-by-isp-workaround/index.md b/app/views/guides/port-25-blocked-by-isp-workaround/index.md deleted file mode 100644 index c080edf0c..000000000 --- a/app/views/guides/port-25-blocked-by-isp-workaround/index.md +++ /dev/null @@ -1,29 +0,0 @@ -# Port 25 blocked by ISP workaround - - -## Table of Contents - -* [How to workaround ISP blocking inbound SMTP on port 25](#how-to-workaround-isp-blocking-inbound-smtp-on-port-25) -* [How to workaround ISP blocking outbound SMTP on port 25](#how-to-workaround-isp-blocking-outbound-smtp-on-port-25) -* [How can I check if my ISP blocks ports](#how-can-i-check-if-my-isp-blocks-ports) - - -## How to workaround ISP blocking inbound SMTP on port 25 - -If you do not have port 25 open on your mail server's IP address, then this guide is for you. - -For example, you're running a custom mail server at home, and your Internet Service Provider ("ISP") has blocked outbound port 25. - -Since you cannot have outbound traffic on port 25, then you most likely also won't have inbound traffic on port 25 due to this block. - -Assuming you are using our service to forward emails, [you can workaround this issue through our FAQ answer here](/faq#can-i-forward-emails-to-ports-other-than-25-eg-if-my-isp-has-blocked-port-25). - - -## How to workaround ISP blocking outbound SMTP on port 25 - -If your ISP blocks outbound port 25, then you will have to find an alternate solution or contact them. - - -## How can I check if my ISP blocks ports - -See our [list of ports blocked by Internet Service Providers](/list-of-ports-blocked-by-isps). diff --git a/app/views/guides/port-25-blocked-by-isp-workaround/index.pug b/app/views/guides/port-25-blocked-by-isp-workaround/index.pug deleted file mode 100644 index 2edd5d890..000000000 --- a/app/views/guides/port-25-blocked-by-isp-workaround/index.pug +++ /dev/null @@ -1,61 +0,0 @@ -extends ../../layout - -include ../../_onboard - -block body - - const hash = "can-i-forward-emails-to-ports-other-than-25-eg-if-my-isp-has-blocked-port-25"; - +onboard(t('Did your ISP block port 25?'), t('Skip ahead to the instructions to forward emails to a custom SMTP port.'), l(`/faq?hash=${hash}`), l(`/faq#${hash}`), 'border-bottom') - .container.py-5 - .row - .col - case locale - when 'ar' - include:md index-ar.md - when 'cs' - include:md index-cs.md - when 'da' - include:md index-da.md - when 'de' - include:md index-de.md - when 'es' - include:md index-es.md - when 'fi' - include:md index-fi.md - when 'fr' - include:md index-fr.md - when 'he' - include:md index-he.md - when 'hu' - include:md index-hu.md - when 'id' - include:md index-id.md - when 'it' - include:md index-it.md - when 'ja' - include:md index-ja.md - when 'ko' - include:md index-ko.md - when 'nl' - include:md index-nl.md - when 'no' - include:md index-no.md - when 'pl' - include:md index-pl.md - when 'pt' - include:md index-pt.md - when 'ru' - include:md index-ru.md - when 'sv' - include:md index-sv.md - when 'th' - include:md index-th.md - when 'tr' - include:md index-tr.md - when 'uk' - include:md index-uk.md - when 'vi' - include:md index-vi.md - when 'zh' - include:md index-zh.md - default - include:md index.md diff --git a/app/views/my-account/billing/_payment-method.pug b/app/views/my-account/billing/_payment-method.pug deleted file mode 100644 index 56b61f769..000000000 --- a/app/views/my-account/billing/_payment-method.pug +++ /dev/null @@ -1,28 +0,0 @@ -mixin paymentMethod(payment) - if payment.method === 'paypal' - i.fab.fa-paypal - | - | PayPal - else - case payment.method - when 'amex' - i.fa.fa-cc-amex - when 'diners' - i.fa.fa-cc-diners-club - when 'discover' - i.fa.fa-cc-discover - when 'jcb' - i.fa.fa-cc-jcb - when 'mastercard' - i.fa.fa-cc-mastercard - when 'visa' - i.fa.fa-cc-visa - default - i.fa.fa-credit-card - = " " - = titleize(humanize(payment.method)) - if payment.last4 - small= " x" - = payment.last4 - if payment.exp_month && payment.exp_year - small.text-muted.text-uppercase= ` ${payment.exp_month}/${payment.exp_year.toString().slice(2)}` diff --git a/app/views/my-account/billing/_receipt.pug b/app/views/my-account/billing/_receipt.pug deleted file mode 100644 index 96159ee1b..000000000 --- a/app/views/my-account/billing/_receipt.pug +++ /dev/null @@ -1,81 +0,0 @@ -include ./_payment-method.pug - -.card.border-dark - .card-header.bg-dark.text-white.p-0 - table.table.mb-0 - tr - td.border-0 - strong - = t("Reference") - = ":" - = " " - = payment.reference - td.text-right.border-0 - strong - = t("Payment Status") - = ":" - = " " - = t("Paid") - table.table.mb-0 - tr - td.border-0(colspan=2) - .text-center.mt-2.mb-3.pb-3.border-bottom - img.d-inline-block.mr-1( - src=manifest(ctx.params.reference.endsWith(".pdf") ? "img/apple-touch-icon.png" : "img/logo-square.svg"), - width=50, - height=50, - alt="" - ) - h1.h2.m-0.align-middle.d-inline-block - span.text-primary.font-weight-bold Forward - = " " - span.text-info.font-weight-normal Email - tr - td.border-0 - address - strong Niftylettuce, LLC. - br - | 16192 Coastal Hwy - br - | Lewes, DE 19958 - br - | United States of America - br - small: a(href="mailto:support@forwardemail.net") support@forwardemail.net - td.border-0.text-right - address - != user[config.userFields.addressHTML] - br - small: a(href=`mailto:${user.email}`)= user.email - tr - td.border-0 - strong - = t("Payment Method") - = ":" - br - +paymentMethod(payment) - td.border-0.text-right - strong - = t("Payment Date") - = ":" - br - = dayjs(_.isDate(payment.invoice_at) ? payment.invoice : payment.created_at).locale(locale).format("M/D/YY") - table.table.table-bordered.mb-0 - thead.thead-dark - tr - th(scope="col")= t("Description") - th.text-right(scope="col")= t("Amount") - tbody - tr - td.align-middle!= payment.description - td.align-middle.text-right= payment.amount_formatted - tfoot.thead-light - tr - th.align-middle - if user[config.userFields.companyVAT] - strong= "VAT #" - = " " - = user[config.userFields.companyVAT] - .float-right.text-right: u= t("Total") - th.align-middle.text-right= payment.amount_formatted - .card-footer.text-center: small.text-muted= t("If you have any questions or comments, then please let us know") diff --git a/app/views/my-account/billing/_table.pug b/app/views/my-account/billing/_table.pug deleted file mode 100644 index 326962adb..000000000 --- a/app/views/my-account/billing/_table.pug +++ /dev/null @@ -1,72 +0,0 @@ -include ../../_sort-header -include ../../_pagination - -include ./_payment-method - -mixin receipt(payment) - .btn-group - a.btn.btn-primary( - href=l(`/my-account/billing/${payment.reference}`), - data-toggle="tooltip", - data-placement="bottom", - title=t("View") - ) - i.fa.fa-receipt - a.btn.btn-danger( - href=l(`/my-account/billing/${payment.reference}.pdf`), - data-toggle="tooltip", - data-placement="bottom", - title=t("Download") - ) - i.fa.fa-file-pdf - a.btn.btn-dark( - href=l(`/my-account/billing/${payment.reference}.html`), - target="_blank", - data-toggle="tooltip", - data-placement="bottom", - title=t("Print") - ) - i.fa.fa-print - -.table-responsive - table.table.table-hover.table-bordered - thead.thead-dark - tr - th.d-none.d-lg-table-cell(scope="col") - +sortHeader('reference', null, '#table-payments') - th(scope="col") - +sortHeader('invoice_at', 'Date', '#table-payments') - th(scope="col") - span.d-none.d-lg-inline - +sortHeader('description', null, '#table-payments') - span.d-lg-none - +sortHeader('reference', 'Payment', '#table-payments') - th.d-none.d-lg-table-cell(scope="col") - +sortHeader('method', null, '#table-payments') - th(scope="col") - +sortHeader('amount', 'Amount', '#table-payments') - th.d-none.d-lg-table-cell.text-center.align-middle(scope="col")= t("Receipt") - tbody - each payment in payments - tr - td.align-middle.d-none.d-lg-table-cell= payment.reference - td.align-middle= dayjs(_.isDate(payment.invoice_at) ? payment.invoice_at : payment.created_at).locale(ctx.locale).format("M/D/YY") - td.align-middle - .d-block.d-lg-none.mb-2 - strong - = t("Reference") - = ":" - = " " - = payment.reference - != payment.description - .d-block.d-lg-none.mb-2 - .d-block.d-lg-none.mb-2 - +paymentMethod(payment) - .d-block.d-lg-none - +receipt(payment) - td.align-middle.d-none.d-lg-table-cell - +paymentMethod(payment) - td.align-middle.text-right= payment.amount_formatted - td.align-middle.d-none.d-lg-table-cell.text-center - +receipt(payment) -+paginate('#table-payments') diff --git a/app/views/my-account/billing/index.pug b/app/views/my-account/billing/index.pug deleted file mode 100644 index b77934ed9..000000000 --- a/app/views/my-account/billing/index.pug +++ /dev/null @@ -1,150 +0,0 @@ -extends ../../layout - -block body - #modal-change-plan.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-change-plan-title", - aria-hidden="true" - ) - .modal-dialog(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-change-plan-title.modal-title.d-inline-block.ml-4= t("Change Plan") - button.close(type="button", data-dismiss="modal", aria-label="Close") - span(aria-hidden="true") × - .modal-body - p.lead.text-center!= t('You are currently on the %s plan.', t(titleize(humanize(user.plan)))) - if user.plan === 'free' - a.btn.btn-lg.btn-block.btn-success.mb-3( - href=l("/my-account/billing/upgrade?plan=enhanced_protection") - )= t("Upgrade to Enhanced Protection") - a.btn.btn-lg.btn-block.btn-primary( - href=l("/my-account/billing/upgrade?plan=team") - )= t("Upgrade to Team") - else if user.plan === 'enhanced_protection' - a.btn.btn-lg.btn-block.btn-primary.mb-3( - href=l("/my-account/billing/upgrade?plan=team") - )= t("Upgrade to Team") - else if user.plan === 'team' - a.btn.btn-lg.btn-block.btn-dark.mb-3( - href=l("/my-account/billing/upgrade?plan=enhanced_protection") - )= t("Downgrade to Enhanced Protection") - if ['enhanced_protection', 'team'].includes(user.plan) - form.confirm-prompt( - action=l("/my-account/billing/upgrade"), - method="GET" - ) - input(type="hidden", name="plan", value="free") - button.btn.btn-block.btn-outline-danger(type="submit")= t("Downgrade to Free") - .modal-footer - button.btn.btn-lg.btn-block.btn-secondary( - type="button", - data-dismiss="modal", - aria-label=t("Cancel") - )= t("Cancel") - - .container-fluid.py-3 - .row - .col - include ../../_breadcrumbs - - .container.pb-5.position-relative - .d-flex.flex-row.align-content-between.flex-wrap.align-items-stretch - .col-12.d-flex(class=user.plan === "free" ? "" : "col-lg-6") - .d-flex.flex-column.flex-grow-1.align-items-center.rounded-lg - h2.display-5.font-weight-bold= t(titleize(humanize(user.plan))) - small.text-muted.text-uppercase.mb-3= t("Current Plan") - button.btn.btn-lg.btn-success( - type="button", - data-toggle="modal", - data-target="#modal-change-plan" - )= t("Change Plan") - if user.plan !== 'free' - .col-12.col-lg-6.d-flex - .mt-lg-0.mt-5.d-flex.flex-column.flex-grow-1.align-items-center.rounded-lg - h2.display-5.font-weight-bold - if user.plan === 'free' - i.fa.fa-infinity - else - - let str = _.capitalize(dayjs(user[config.userFields.planExpiresAt]).locale(ctx.locale).fromNow()); - if str.startsWith('A ') - - str = str.replace("A ", "1 "); - else if str.startsWith('In a') - - str = str.replace("In a ", "1 "); - else if str.startsWith('In ') - - str = str.replace("In ", ""); - = str - small.text-muted.text-uppercase.mb-3 - = t("Next Payment Due") - = ": " - if user.plan === 'free' - i.fa.fa-infinity - else - = dayjs(user[config.userFields.planExpiresAt]).locale(ctx.locale).format("M/D/YY") - if isSANB(user[config.userFields.stripeSubscriptionID]) - .col-12.d-flex.col-lg-6 - .d-flex.flex-column.flex-grow-1.align-items-center.rounded-lg - form( - action=l("/my-account/billing/manage-payments"), - method="POST" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-lg.btn-danger(type="submit")= t("Manage Billing") - else - a.btn.btn-lg.btn-danger( - href=l("/my-account/billing/make-payment"), - disabled=user.plan === "free", - class=user.plan === "free" ? "disabled" : "" - )= t("Make Payment") - if payments.length > 0 - .container.py-3 - .row - .col - h3.text-center.mb-3= t("Payment History") - p.text-center: small.text-muted!= t('If you want to update company info on receipts, then update your profile.', l("/my-account/profile")) - //-. - form.ajax-form.table-ajax-form.mb-3.px-3( - action=ctx.path, - method="GET", - data-table="#table-payments" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - .form-row - .col.form-group.floating-label - input#input-keyword-search.form-control( - type="text", - value=ctx.query.keyword ? ctx.query.keyword : '', - name="keyword", - placeholder=t("Keyword") - ) - label(for="input-keyword-search")= t('Keyword') - .form-row - .col.form-group.floating-label - input#input-start-date.form-control( - type="date", - value=ctx.query.start_date ? ctx.query.start_date : '', - name="start_date" - ) - label(for="input-start-date")= t('Start Date') - .col.form-group.floating-label - input#input-end-date.form-control( - type="date", - value=ctx.query.end_date ? ctx.query.end_date : '', - name="end_date" - ) - label(for="input-end-date")= t('End Date') - .row - .col - button.btn.btn-success.btn-block(type="submit")= t('Search') - #table-payments.mb-0 - include ./_table.pug - if isSANB(user[config.userFields.stripeSubscriptionID]) || isSANB(user[config.userFields.paypalSubscriptionID]) - .container(class=payments.length === 0 ? "py-3" : "") - .row.text-center.my-5 - .col - form.confirm-prompt(action=l("/my-account/billing"), method="POST") - input(type="hidden", name="_csrf", value=ctx.csrf) - input(type="hidden", name="_method", value="DELETE") - button.btn.btn-outline-secondary(type="submit") - = t("Cancel Subscription") diff --git a/app/views/my-account/billing/pdf.pug b/app/views/my-account/billing/pdf.pug deleted file mode 100644 index 106b84bdd..000000000 --- a/app/views/my-account/billing/pdf.pug +++ /dev/null @@ -1,16 +0,0 @@ -extends ../../layout - -block spinner -block navigation -block footer - -block append scripts - if ctx.params.reference.endsWith('.html') - script. - window.print(); - -block body - .container.p-5 - .row - .col - include _receipt diff --git a/app/views/my-account/billing/receipt.pug b/app/views/my-account/billing/receipt.pug deleted file mode 100644 index ccad43fda..000000000 --- a/app/views/my-account/billing/receipt.pug +++ /dev/null @@ -1,41 +0,0 @@ -extends ../../layout - -block body - .container-fluid.py-3 - .row - .col - include ../../_breadcrumbs - - .container - .row - .col - include _receipt - .row.my-5 - ul.list-inline.mb-0.mx-auto - li.list-inline-item - a.btn.btn-danger( - href=l(`/my-account/billing/${payment.reference}.pdf`), - data-toggle="tooltip", - data-placement="bottom", - title=t("Download") - ) - i.fa.fa-file-pdf - = " " - = t("Download") - li.list-inline-item - a.btn.btn-dark( - href=l(`/my-account/billing/${payment.reference}.html`), - target="_blank", - data-toggle="tooltip", - data-placement="bottom", - title=t("Print") - ) - i.fa.fa-print - = " " - = t("Print") - .row.text-center.my-5 - .col - a.btn.btn-outline-secondary(href=l("/my-account/billing")) - i.fa.fa-angle-double-left - = " " - = t("Back to Billing") diff --git a/app/views/my-account/domains/_modal-domain.pug b/app/views/my-account/domains/_modal-domain.pug deleted file mode 100644 index 3f560df5b..000000000 --- a/app/views/my-account/domains/_modal-domain.pug +++ /dev/null @@ -1,41 +0,0 @@ -#modal-domain.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-domain-title", - aria-hidden="true" -) - .modal-dialog(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-domain-title.modal-title.d-inline-block.ml-4= t("Add New Domain") - button.close(type="button", data-dismiss="modal", aria-label="Close") - span(aria-hidden="true") × - .modal-body - form.ajax-form(action=l("/my-account/domains/new"), method="POST") - input(type="hidden", name="_csrf", value=ctx.csrf) - .form-group.floating-label - input#input-domain.form-control.form-control-lg( - required, - name="domain", - autocomplete="off", - placeholder="example.com", - autocorrect="off", - spellcheck="false", - autocapitalize="off", - type="text" - ) - label(for="input-domain")= t("Domain name") - if user.group === 'admin' - .form-group.form-check - input#input-is-global.form-check-input( - type="checkbox", - name="is_global", - value="true" - ) - label.form-check-label(for="input-is-global")= t("Global domain") - button.btn.btn-success.btn-block.btn-lg(type="submit")= t("Continue") - .alert.alert-warning.mt-3.mb-0.text-center - ul.list-inline.mb-0 - li.list-inline-item= t("Don't have a domain name?") - li.list-inline-item - a.alert-link(href=l("/domain-registration"))= t("Register a domain") diff --git a/app/views/my-account/domains/_modal-upgrade.pug b/app/views/my-account/domains/_modal-upgrade.pug deleted file mode 100644 index 7bf12986e..000000000 --- a/app/views/my-account/domains/_modal-upgrade.pug +++ /dev/null @@ -1,24 +0,0 @@ -#modal-upgrade.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-domain-title", - aria-hidden="true" -) - .modal-dialog(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-upgrade-title.modal-title.d-inline-block.ml-4= t("Upgrade your domain's plan") - button.close(type="button", data-dismiss="modal", aria-label="Close") - span(aria-hidden="true") × - .modal-body.lead - p= t("Please upgrade your domain's plan to use our privacy-focused and encrypted email alias manager (instead of managing your aliases for free with public DNS records).") - p!= t('If you would like to learn more about DNS-based aliases (free plan), then please follow these instructions on our FAQ page.', domain ? l(`/faq?domain=${domain.name}#how-do-i-get-started-and-set-up-email-forwarding`) : l("/faq#how-do-i-get-started-and-set-up-email-forwarding")) - if domain && domain.plan === 'free' - a.btn.btn-dark.btn-lg.btn-block( - href=l(`/my-account/domains/${domain.name}/billing?plan=enhanced_protection`) - ) - = t("Upgrade to Enhanced Protection") - = " " - i.fa.fa-angle-double-right - else - p= t("To upgrade your domain's plan, just close this modal and then click on the Current Plan dropdown button.") diff --git a/app/views/my-account/domains/_table.pug b/app/views/my-account/domains/_table.pug deleted file mode 100644 index 1a35182d2..000000000 --- a/app/views/my-account/domains/_table.pug +++ /dev/null @@ -1,171 +0,0 @@ -include ../../_sort-header -include ../../_pagination - -table.table.table-hover.table-bordered - thead.thead-dark - tr - th(scope="col") - +sortHeader('name', 'Domain Name', '#table-domains') - if user.group === 'admin' - th.text-center(scope="col") - +sortHeader('is_global', 'Global', '#table-domains') - th.d-none.d-md-table-cell(scope="col") - +sortHeader('plan', 'Current Plan', '#table-domains') - th.d-none.d-md-table-cell.text-center(scope="col") - +sortHeader('aliases', 'Aliases', '#table-domains') - th.d-none.d-md-table-cell.text-center(scope="col") - +sortHeader('has_mx_record', 'MX Records', '#table-domains') - th.d-none.d-md-table-cell.text-center(scope="col") - +sortHeader('has_txt_record', 'TXT Records', '#table-domains') - th.text-center.text-md-left.align-middle(scope="col")= t("Quick Links") - tbody - if domains.length === 0 - td.alert.alert-info(colspan=user.group === "admin" ? "7" : "6")= t("No domains exist for that search.") - else - each domain in domains - tr - td.align-middle - .markdown-body.ml-0.mr-0: code= domain.name - if domain.is_global - .mb-0.mt-2: span.badge.badge-success.font-weight-bold.text-uppercase= t("Free Vanity Domain") - if user.group === 'admin' - td.align-middle.text-center - if domain.is_global - i.fa.fa-fw.fa-check.fa-2x.text-success - td.align-middle.d-none.d-md-table-cell - - domain.plan = domain.plan || "free"; - if domain.group === 'admin' - .btn-group - button.btn.btn-dark.dropdown-toggle( - type="button", - data-toggle="dropdown", - aria-haspopup="true", - aria-expanded="false" - ) - case domain.plan - when 'enhanced_protection' - i.fa.fa-fw.fa-shield - = " " - = t("Enhanced Protection") - when 'team' - i.fa.fa-fw.fa-users - = " " - = t("Team") - default - = t("Free") - = " (" - = t("DNS-based") - = ")" - .dropdown-menu - h6.dropdown-header.text-uppercase= t("Change plan to:") - each plan in [ 'free', 'enhanced_protection', 'team' ].filter(plan => plan !== domain.plan) - a.dropdown-item( - href=l(`/my-account/domains/${domain.name}/billing?plan=${plan}`) - ) - case plan - when 'enhanced_protection' - i.fa.fa-fw.fa-shield - = " " - = t("Enhanced Protection") - when 'team' - i.fa.fa-fw.fa-users - = " " - = t("Team") - default - = t("Free") - = " (" - = t("DNS-based") - = ")" - .dropdown-divider - a.dropdown-item(href=l(`/pricing?domain=${domain.name}`)) - small.text-muted= t("See all plan features") - else - = t(titleize(humanize(domain.plan))) - if domain.plan === 'free' - td.align-middle.d-none.d-md-table-cell.text-center= t("DNS-based") - else - td.align-middle.d-none.d-md-table-cell.text-center.h4: a.badge.badge-pill.badge-dark.px-3.py-2.font-weight-normal( - href=l(`/my-account/domains/${domain.name}/aliases`), - data-toggle="tooltip", - title=t("Manage Aliases") - )= domain.aliases.length - td.align-middle.d-none.d-md-table-cell.text-center - if !domain.is_global || domain.is_global && domain.group === 'admin' - ul.list-inline.mb-0 - li.list-inline-item - button.btn.btn-link( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=domain.has_mx_record ? t("Verified") : t("Not Verified"), - class=domain.has_mx_record ? "text-success" : "text-danger" - ) - i.fa.fa-fw.fa-2x( - class=domain.has_mx_record ? "fa-check" : "fa-times" - ) - li.list-inline-item - form.ajax-form.d-inline-block( - action=l(`/my-account/domains/${domain.name}/verify-records`), - method="POST" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-outline-dark(type="submit") - i.fa.fa-fw.fa-refresh.d-block.d-lg-inline - td.align-middle.d-none.d-md-table-cell.text-center - if !domain.is_global || domain.is_global && domain.group === 'admin' - ul.list-inline.mb-0 - li.list-inline-item - button.btn.btn-link( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=domain.has_txt_record ? t("Verified") : t("Not Verified"), - class=domain.has_txt_record ? "text-success" : "text-danger" - ) - i.fa.fa-fw.fa-2x( - class=domain.has_txt_record ? "fa-check" : "fa-times" - ) - li.list-inline-item - form.ajax-form.d-inline-block( - action=l(`/my-account/domains/${domain.name}/verify-records`), - method="POST" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-outline-dark(type="submit") - i.fa.fa-fw.fa-refresh.d-block.d-lg-inline - td.align-middle - ul.list-inline.mb-0 - if domain.group === 'admin' - li.list-inline-item.d-block.d-lg-inline-block.mr-0.mr-md-1.my-2 - a.btn.btn-dark.btn-block( - href=l(`/my-account/domains/${domain.name}`) - ) - i.fa.fa-fw.fa-cog - = " " - = t("Setup") - li.list-inline-item.d-block.d-lg-inline-block.mr-0.mr-md-1.my-2 - a.btn.btn-dark.btn-block( - href=domain.plan === "free" ? "#" : l(`/my-account/domains/${domain.name}/aliases`), - role=domain.plan === "free" ? "button" : false, - data-target=domain.plan === "free" ? "#modal-upgrade" : false, - data-toggle=domain.plan === "free" ? "modal" : false - ) - i.fa.fa-fw.fa-users - = " " - = t("Aliases") - if domain.group === 'admin' - li.list-inline-item.d-block.d-lg-inline-block.mr-0.mr-md-1.my-2 - a.btn.btn-dark.btn-block( - href=l(`/my-account/domains/${domain.name}/advanced-settings`) - ) - i.fa.fa-fw.fa-tools - = " " - = t("Advanced Settings") - //-. - if domain.group === 'admin' - li.list-inline-item.d-block.d-lg-inline-block - a(href=l(`/my-account/domains/${domain.name}/billing`)).btn.btn-dark.btn-block - i.fa.fa-fw.fa-receipt - = ' ' - = t('Billing') -+paginate('#table-domains') diff --git a/app/views/my-account/domains/advanced-settings.pug b/app/views/my-account/domains/advanced-settings.pug deleted file mode 100644 index f67fb67fa..000000000 --- a/app/views/my-account/domains/advanced-settings.pug +++ /dev/null @@ -1,617 +0,0 @@ -extends ../../layout - -block append scripts - script. - window.IS_CUSTOM_VERIFICATION_DISABLED = !{domain.plan === 'free' || !domain.has_custom_verification}; - - script( - defer, - src=manifest("js/advanced-settings.js"), - integrity=manifest("js/advanced-settings.js", "integrity"), - crossorigin="anonymous" - ) - -block append stylesheets - link( - rel="stylesheet", - href=manifest("css/codemirror.css"), - integrity=manifest("css/codemirror.css", "integrity"), - crossorigin="anonymous" - ) - -mixin planRequired(isTeam = false) - if domain.plan === 'free' || (isTeam && domain.plan !== 'team') - .alert.alert-warning!= t('Please upgrade to the %s plan to unlock this feature.', l(`/my-account/domains/${domain.name}/billing?plan=${isTeam ? "team" : "enhanced_protection"}`), isTeam ? "Team" : "Enhanced Protection") - -block body - - const isDisabled = domain.plan === "free"; - - const isTeamDisabled = domain.plan !== "team"; - if domain.group === 'admin' - #modal-invite.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-invite-title", - aria-hidden="true" - ) - .modal-dialog(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-invite-title.modal-title.d-inline-block.ml-4= t("Invite new team member") - button.close( - type="button", - data-dismiss="modal", - aria-label="Close" - ) - span(aria-hidden="true") × - .modal-body - .text-center - +planRequired(true) - form.ajax-form.confirm-prompt( - action=l(`/my-account/domains/${domain.name}/invites`), - method="POST", - disabled=isTeamDisabled - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - .form-group - label(for="input-invite-email")= t("Email address") - input#input-invite-email.form-control( - required, - disabled=isTeamDisabled, - autocomplete="off", - name="email", - type="email", - placeholder="name@example.com" - ) - label= t("Group") - .form-group - .form-check.form-check-inline - input#input-invite-group-user.form-check-input( - type="radio", - disabled=isTeamDisabled, - name="group", - value="user", - checked - ) - label.form-check-label(for="input-invite-group-user")= t("User") - .form-check.form-check-inline - input#input-invite-group-admin.form-check-input( - type="radio", - disabled=isTeamDisabled, - name="group", - value="admin" - ) - label.form-check-label(for="input-invite-group-admin")= t("Admin") - button.btn.btn-block.btn-success.btn-lg( - type="submit", - disabled=isTeamDisabled - )= t("Send invitation") - #modal-copy-invite.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-copy-invite-title", - aria-hidden="true" - ) - .modal-dialog.modal-lg(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-copy-invite-title.modal-title.d-inline-block.ml-4= t("Share invitation link") - button.close( - type="button", - data-dismiss="modal", - aria-label="Close" - ) - span(aria-hidden="true") × - .modal-body - .text-center - +planRequired(true) - .input-group.input-group-lg - input#invite-link.form-control( - type="text", - readonly, - value=isTeamDisabled ? "" : `${config.urls.web}/my-account/domains/${domain.id}/invites` - ) - .input-group-append - button.btn.btn-primary( - type="button", - data-toggle="clipboard", - data-clipboard-target="#invite-link", - disabled=isTeamDisabled - ) - i.fa.fa-clipboard - = " " - = t("Copy Invite Link") - .container-fluid.pt-3 - .row - .col - include ../../_breadcrumbs - .row - .col - if domain.group === 'admin' - .card.border-dark.mb-3 - h4.card-header.bg-dark.text-white= t("Current Plan") - .card-body - p.card-text - != t('This domain is currently on the %s plan. Visit your Billing page and our Pricing page to learn more and compare plans.', t(titleize(humanize(domain.plan))), l("/my-account/billing"), l(`/pricing?domain=${domain.name}`)) - .card-footer - .btn-group - button.btn.btn-lg.btn-primary.dropdown-toggle( - type="button", - data-toggle="dropdown", - aria-haspopup="true", - aria-expanded="false" - )= t("Change plan") - .dropdown-menu.dropdown-menu-right - h6.dropdown-header.text-uppercase= t("Change plan to:") - each plan in [ 'free', 'enhanced_protection', 'team' ].filter(plan => plan !== domain.plan) - a.dropdown-item( - href=l(`/my-account/domains/${domain.name}/billing?plan=${plan}`) - ) - case plan - when 'enhanced_protection' - i.fa.fa-fw.fa-shield - = " " - = t("Enhanced Protection") - when 'team' - i.fa.fa-fw.fa-users - = " " - = t("Team") - default - = t("Free") - = " (" - = t("DNS-based") - = ")" - .dropdown-divider - a.dropdown-item(href=l(`/pricing?domain=${domain.name}`)) - small.text-muted= t("See all plan features") - if domain.group === 'admin' - - const hasInvites = Array.isArray(domain.invites) && domain.invites.length > 0 && domain.plan === "team"; - .card.border-dark.mb-3 - h4.card-header.bg-dark.text-white= t("Manage Team") - .card-body - +planRequired(true) - p.card-text= t("Members have shared access to this domain, and you can invite new members, remove existing members, or manage permissions for them below.") - .table-responsive - table.table.table-hover.table-bordered(class=hasInvites ? "" : "mb-0") - thead.thead-dark - tr - th(scope="col")= t("Team Member") - th.text-center(scope="col")= t("Alias Count") - if domain.members.length > 1 - th.text-center.text-md-left(scope="col")= t("Quick Links") - tbody - each member in domain.members - //- member.user (Object) and member.group (String) - //- member.user.id (String) - //- member.user.email (String) - tr - td.align-middle= member.user.email - td.align-middle.text-center - .h3.mb-0: span.badge.badge-pill.badge-dark.text-monospace.font-weight-normal= member.alias_count - if domain.members.length > 1 - td.align-middle - ul.list-inline.mb-0 - li.list-inline-item - form.ajax-form.confirm-prompt.d-inline-block( - action=l(`/my-account/domains/${domain.name}/members/${member.user.id}`), - method="POST", - disabled=isTeamDisabled - ) - input( - type="hidden", - name="_csrf", - value=ctx.csrf - ) - input( - type="hidden", - name="_method", - value="PUT" - ) - input( - type="hidden", - name="group", - value=member.group === "admin" ? "user" : "admin" - ) - button.btn.btn-dark( - type="submit", - disabled=isTeamDisabled - )= member.group === "admin" ? t("Make User") : t("Make Admin") - //- if member.user.id !== user.id - li.list-inline-item - form.ajax-form.confirm-prompt.d-inline-block( - action=l(`/my-account/domains/${domain.name}/members/${member.user.id}`), - method="POST", - disabled=isTeamDisabled - ) - input( - type="hidden", - name="_csrf", - value=ctx.csrf - ) - input( - type="hidden", - name="_method", - value="DELETE" - ) - button.btn.btn-danger( - type="submit", - disabled=isTeamDisabled - ) - i.fa.fa-trash - = " " - = t("Delete Member") - if hasInvites - p.card-text= t("Pending invitations are listed below.") - .table-responsive - table.table.table-hover.table-bordered.mb-0 - thead.thead-dark - tr - th(scope="col")= t("Email Address") - th(scope="col")= t("Group") - th(scope="col")= t("Quick Links") - tbody - each invite in domain.invites - tr - td.align-middle= invite.email - td.align-middle= titleize(invite.group) - td.align-middle - ul.list-inline.mb-0 - //- copy invite link - li.list-inline-item - button.btn.btn-primary( - type="button", - data-toggle="modal", - data-target="#modal-copy-invite", - disabled=isTeamDisabled - ) - i.fa.fa-clipboard - = " " - = t("Copy Invite Link") - //- delete invite - li.list-inline-item - form.ajax-form.confirm-prompt.d-inline-block( - action=l(`/my-account/domains/${domain.name}/invites`), - method="POST", - disabled=isTeamDisabled - ) - input( - type="hidden", - name="_csrf", - value=ctx.csrf - ) - input( - type="hidden", - name="_method", - value="DELETE" - ) - input( - type="hidden", - name="email", - value=invite.email - ) - button.btn.btn-danger( - type="submit", - disabled=isTeamDisabled - ) - i.fa.fa-trash - = " " - = t("Delete Invite") - - .card-footer - //- TODO: this should be a right-clickable link with re-usable form in the modal (and modal-anchor) - button.btn.btn-lg.btn-primary( - type="button", - data-target="#modal-invite", - data-toggle="modal" - ) - i.fa.fa-user-plus - = " " - = t("Invite New Member") - - //- Has Recipient Verification - if domain.group === 'admin' - form.ajax-form.confirm-prompt.card.border-dark.mb-3( - action=ctx.path, - method="POST", - disabled=isDisabled - ) - input(type="hidden", name="_method", value="PUT") - input(type="hidden", name="_csrf", value=ctx.csrf) - input( - type="hidden", - name="_section", - value="recipient_verification" - ) - h4.card-header.bg-dark.text-white= t("Recipient Verification") - .card-body - +planRequired(false) - .form-group.form-check - input#input-has-recipient-verification.form-check-input( - type="checkbox", - disabled=isDisabled, - name="has_recipient_verification", - value="true", - checked=domain.has_recipient_verification - ) - label.form-check-label(for="input-has-recipient-verification")= t("Requires recipients to click email verification link") - p.form-text: small.text-black-50= t("If you check this, then each email recipient will be required to click an email verification link in order for emails to flow through.") - .card-footer - ul.list-inline.mb-0 - li.list-inline-item - button.confirm-prompt.btn.btn-lg.btn-secondary( - type="reset", - disabled=isDisabled - )= t("Reset") - li.list-inline-item - button.btn.btn-lg.btn-primary( - type="submit", - disabled=isDisabled - )= t("Save") - - //- Custom Verification Template - - const isCustomVerificationDisabled = domain.plan === "free" || !domain.has_custom_verification; - if domain.group === 'admin' - form.ajax-form.confirm-prompt.card.border-dark.mb-3( - action=ctx.path, - method="POST", - disabled=isCustomVerificationDisabled - ) - input(type="hidden", name="_method", value="PUT") - input(type="hidden", name="_csrf", value=ctx.csrf) - input( - type="hidden", - name="_section", - value="custom_verification_template" - ) - h4.card-header.bg-dark.text-white= t("Custom Verification Template") - .card-body - +planRequired(false) - if !domain.has_custom_verification && domain.plan !== 'free' - .alert.alert-warning!= t('Please contact us to unlock this feature.', l("/help")) - //- custom_verification.name - .form-group.floating-label - input#input-custom-verification-name.form-control.form-control-lg( - value=domain.custom_verification && isSANB(domain.custom_verification.name) ? domain.custom_verification.name : "", - name="custom_verification_name", - autocomplete="off", - placeholder="Sender name", - autocorrect="off", - spellcheck="false", - autocapitalize="off", - disabled=isCustomVerificationDisabled, - type="text" - ) - label(for="input-custom-verification-name")= t("Sender name") - - //- custom_verification.email - .form-group.floating-label - input#input-custom-verification-email.form-control.form-control-lg( - value=domain.custom_verification && isSANB(domain.custom_verification.email) ? domain.custom_verification.email : "", - name="custom_verification_email", - autocomplete="email", - placeholder="Sender email", - autocorrect="off", - spellcheck="false", - disabled=isCustomVerificationDisabled, - type="email" - ) - label(for="input-custom-verification-email")= t("Sender email") - - //- custom_verification.subject - .form-group.floating-label - input#input-custom-verification-subject.form-control.form-control-lg( - value=domain.custom_verification && isSANB(domain.custom_verification.subject) ? domain.custom_verification.subject : "", - name="custom_verification_subject", - autocomplete="off", - placeholder="Subject line", - autocorrect="off", - spellcheck="false", - autocapitalize="off", - disabled=isCustomVerificationDisabled, - type="text" - ) - label(for="input-custom-verification-subject")= t("Subject line") - - //- custom_verification.redirect - .form-group.floating-label - input#input-custom-verification-redirect.form-control.form-control-lg( - value=domain.custom_verification && isSANB(domain.custom_verification.redirect) ? domain.custom_verification.redirect : "", - name="custom_verification_redirect", - autocomplete="off", - placeholder="Verification redirect link", - autocorrect="off", - spellcheck="false", - autocapitalize="off", - disabled=isCustomVerificationDisabled, - type="url" - ) - label(for="input-custom-verification-redirect")= t("Verification redirect link") - p.form-text: small.text-black-50= t("If you set a value here, then once a verification link was clicked and it was successful, it will redirect the users to this URL.") - - .form-group - label(for="#textarea-custom-verification-html")= t("HTML Content") - .alert.alert-warning= t("Please leave this section blank if you wish to use our default HTML template.") - .alert.alert-info - != t('Please make sure to include the string "VERIFICATION_LINK" without quotes at least once to insert the verification link into the email message.') - = " " - != t('You can optionally include the string "FROM_EMAIL" and/or "TO_EMAIL" to insert the forwarding "From" address and the forwarding "To" address to give the user context.') - textarea#textarea-custom-verification-html( - name="custom_verification_html", - disabled=isCustomVerificationDisabled - )= domain.custom_verification && isSANB(domain.custom_verification.html) ? domain.custom_verification.html : "" - - .form-group - label= t("HTML Preview") - iframe#custom-verification-preview.border.border-dark.mb-3.min-vh-80.w-100( - src="about:blank" - ) - - //- TODO: eventually let users customize the text portion (for now we use html-to-text) - - .card-footer - ul.list-inline.mb-0 - li.list-inline-item - button.confirm-prompt.btn.btn-lg.btn-secondary( - type="reset", - disabled=isCustomVerificationDisabled - )= t("Reset") - li.list-inline-item - button.btn.btn-lg.btn-primary( - type="submit", - disabled=isCustomVerificationDisabled - )= t("Save") - - //- Spam Scanner Settings - if domain.group === 'admin' - form.ajax-form.confirm-prompt.card.border-dark.mb-3( - action=ctx.path, - method="POST", - disabled=isDisabled - ) - input(type="hidden", name="_method", value="PUT") - input(type="hidden", name="_csrf", value=ctx.csrf) - input( - type="hidden", - name="_section", - value="spam_scanner_settings" - ) - h4.card-header.bg-dark.text-white= t("Spam Scanner Settings") - .card-body - +planRequired(false) - p.card-text - != t('Spam Scanner is the open-source technology we built ourselves for anti-spam, phishing, and virus protection.', "https://spamscanner.net") - = " " - != t("Since it was created by the same team that brought you Forward Email, it also abides by the same privacy-first and zero-logging policies.") - = " " - != t('You can learn more at %s.', "https://spamscanner.net", "https://spamscanner.net") - .form-group.form-check - input#input-has-adult-protection.form-check-input( - type="checkbox", - disabled=isDisabled, - name="has_adult_content_protection", - value="true", - checked=domain.has_adult_content_protection - ) - label.form-check-label(for="input-has-adult-protection")= t("Adult-related content protection") - p.form-text: small.text-black-50!= t("If you uncheck this, then links will not be scanned for adult-related content.") - .form-group.form-check - input#input-has-phishing-protection.form-check-input( - type="checkbox", - disabled=isDisabled, - name="has_phishing_protection", - value="true", - checked=domain.has_phishing_protection - ) - label.form-check-label(for="input-has-phishing-protection")= t("Phishing protection") - p.form-text: small.text-black-50!= t('If you uncheck this, then links will not be scanned for malware, domain swapping, IDN homograph attacks, nor phishing in general.', "https://en.wikipedia.org/wiki/IDN_homograph_attack") - .form-group.form-check - input#input-has-executable-protection.form-check-input( - type="checkbox", - disabled=isDisabled, - name="has_executable_protection", - value="true", - checked=domain.has_executable_protection - ) - label.form-check-label(for="input-has-executable-protection")= t("Executable protection") - p.form-text: small.text-black-50!= t('If you uncheck this, then links and attachments will not be scanned for potentially-malicious executable file types, extensions, names, headers, IDN homograph attacks, nor magic numbers.', "https://github.com/spamscanner/spamscanner/blob/master/executables.json", "https://en.wikipedia.org/wiki/IDN_homograph_attack", "https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files") - .form-group.form-check - input#input-has-virus-protection.form-check-input( - type="checkbox", - disabled=isDisabled, - name="has_virus_protection", - value="true", - checked=domain.has_virus_protection - ) - label.form-check-label(for="input-has-virus-protection")= t("Virus protection") - p.form-text: small.text-black-50!= t('If you uncheck this, then attachments will not be scanned for trojans, viruses, malware, nor other malicious threats with ClamAV.', "https://www.clamav.net/") - .card-footer - ul.list-inline.mb-0 - li.list-inline-item - button.confirm-prompt.btn.btn-lg.btn-secondary( - type="reset", - disabled=isDisabled - )= t("Reset") - li.list-inline-item - button.btn.btn-lg.btn-primary( - type="submit", - disabled=isDisabled - )= t("Save") - if domain.group === 'admin' - form.ajax-form.confirm-prompt.card.border-dark.mb-3( - action=ctx.path, - method="POST", - disabled=isDisabled - ) - input(type="hidden", name="_method", value="PUT") - input(type="hidden", name="_csrf", value=ctx.csrf) - h4.card-header.bg-dark.text-white= t("Custom SMTP Port Forwarding") - .card-body - +planRequired(false) - .form-group.floating-label - input#input-port.form-control.form-control-lg( - type="port", - disabled=isDisabled, - required, - name="smtp_port", - value=domain.smtp_port, - placeholder=" ", - aria-describedby="help-port" - ) - label(for="input-port")= t("Port number (SMTP)") - p.card-text: small#help-port.form-text.text-muted - = t("Do not modify this unless you know what you are doing. This will forward all emails to the specific port for all aliases and their recipients. For example, if you are forwarding to info@example.com, and input port number 1337 here, then our server will forward email to example.com on port 1337 (as opposed to the standard SMTP port of 25).") - if domain.plan === 'free' - = " " - != t('If you are on the free plan, then you can use the "forward-email-port" DNS TXT record.', l("/faq#can-i-forward-emails-to-ports-other-than-25-eg-if-my-isp-has-blocked-port-25")) - .card-footer - ul.list-inline.mb-0 - li.list-inline-item - button.confirm-prompt.btn.btn-lg.btn-secondary( - type="reset", - disabled=isDisabled - )= t("Reset") - li.list-inline-item - button.btn.btn-lg.btn-primary( - type="submit", - disabled=isDisabled - )= t("Save") - .card.border-dark.mb-3 - h4.card-header.bg-dark.text-white= t("Maximum Recipients Per Alias") - .card-body - if domain.group === 'admin' - +planRequired(false) - p.card-text - != t('You are currently limited to forwarding to %d recipients per alias.', domain.max_recipients_per_alias <= 0 ? config.maxForwardedAddresses : domain.max_recipients_per_alias) - = " " - != t('If you need to increase this limit, then please contact %s and include a valid reason.', domain.group === "admin" ? t("us") : t("your domain's administrator")) - if domain.group === 'admin' - .card-footer - a.btn.btn-lg.btn-primary( - href=l("/help"), - role="button", - data-target="#modal-help", - disabled=isDisabled - )= t("Increase limit") - if domain.group === 'admin' - .card.border-dark.mb-3 - h4.card-header.bg-dark.text-white= t("Delete Domain") - .card-body - = t("Deleting your domain is irreversable. Please take extreme caution when deleting your domain.") - .card-footer - form.ajax-form.confirm-prompt( - action=l(`/my-account/domains/${domain.name}`), - method="POST" - ) - input(type="hidden", name="_method", value="DELETE") - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-sm.btn-danger(type="submit") - i.fa.fa-trash - = " " - = t("Delete Domain") - .row.text-center.my-5 - .col - a.btn.btn-outline-secondary( - href=l(domain.group === "admin" ? `/my-account/domains/${domain.name}` : "/my-account/domains") - ) - i.fa.fa-angle-double-left - = " " - = domain.group === "admin" ? t("Back to Domain") : t("Back to Domains") diff --git a/app/views/my-account/domains/aliases/_form.pug b/app/views/my-account/domains/aliases/_form.pug deleted file mode 100644 index 3d09b49d2..000000000 --- a/app/views/my-account/domains/aliases/_form.pug +++ /dev/null @@ -1,135 +0,0 @@ -- const domainQuery = isSANB(ctx.query.domain) && (validator.isIP(ctx.query.domain) || isFQDN(ctx.query.domain)) ? ctx.query.domain : false; -input(type="hidden", name="_csrf", value=ctx.csrf) -if alias - input(type="hidden", name="_method", value="PUT") -.form-row - .form-group(class=domain ? "col" : "col col-md-6") - label(for="input-alias-name") - = t("Alias Name") - = " " - span.text-danger= t("(required)") - .input-group - .input-group-prepend - button.generate-random-alias.btn.btn-dark.border-dark( - data-target="#input-alias-name", - type="button", - data-toggle="tooltip", - title=t("Generate random alias") - ) - i.fa.fa-dice - input#input-alias-name.form-control.form-control-lg.border-dark( - required, - value=alias ? alias.name : "", - name="name", - autocomplete="off", - placeholder="", - autocorrect="off", - spellcheck="false", - autocapitalize="off", - type="text" - ) - .input-group-append - .input-group-text.bg-dark.text-white.border-dark - if domain - input(type="hidden", name="domain", required, value=domain.id) - = `@${domain.name}` - else - i.fa.fa-at - ul.list-inline - li.list-inline-item - small.text-black-50 - = t('Use an asterisk "*" for a catch-all alias.') - li.list-inline-item - small.text-black-50 - != t('We also support regular expressions.', l("/faq#do-you-support-regular-expressions-or-regex")) - if !domain - .form-group.col-md-6 - label(for="select-alias-domain") - = t("Domain Name") - select#select-alias-domain.form-control.form-control-lg( - required, - name="domain" - ) - each domain, i in domains - option( - value=domain.id, - selected=domainQuery && domainQuery === domain.name - ) - = domain.name - if !domain.plan || domain.plan === 'free' - = " " - = "(" - = t("Upgrade plan") - = ")" - if domain.is_global - = " (" - = t("Free Vanity Domain") - = ")" - - const hasFreeDomains = domains.some((domain) => !domain.plan || domain.plan === "free"); - ul.list-inline.form-text - if hasFreeDomains - li.list-inline-item - a.btn.btn-sm.btn-primary( - href=l(`/pricing${domainQuery ? `?domain=${domainQuery}` : ""}`) - ) - i.fa.fa-angle-double-up - = " " - = t("Upgrade plan") - li.list-inline-item - a.btn.btn-sm.btn-success( - href=l("/my-account/domains/new"), - role="button", - data-toggle="modal-anchor", - data-target="#modal-domain" - ) - i.fa.fa-plus - = " " - = t("Add new domain") - if hasFreeDomains - - const match = domains.find((domain) => domain.name === domainQuery); - if match && match.id - li.list-inline-item - a.btn.btn-sm.btn-link.text-muted( - href=l(`/my-account/domains/${match.id}`) - )= t("Need free version?") -.form-group - label(for="textarea-alias-recipients") - = t("Alias Recipients") - = " " - span.text-danger= t("(required)") - textarea#textarea-alias-recipients.form-control.form-control-lg( - name="recipients", - rows=2 - )= alias && Array.isArray(alias.recipients) ? alias.recipients.join(", ") : user.email - p.form-text: small.text-black-50= t('Recipients must be a line-break/space/comma separated list of valid email addresses, fully-qualified domain names ("FQDN"), IP addresses, and/or webhook URL\'s. We will automatically remove duplicate entries for you and perform validation when you click "Continue" below.') -.form-group - label(for="textarea-alias-description")= t("Alias Description (optional)") - textarea#textarea-alias-description.form-control.form-control-lg( - name="description", - maxlength=150, - rows=2 - )= alias ? alias.description : "" - p.form-text: small.text-black-50= t("Description has a max of 150 characters.") -.form-group - label(for="input-alias-labels")= t("Alias Labels (optional)") - textarea#input-alias-labels.form-control.form-control-lg(name="labels")= alias && Array.isArray(alias.labels) ? _.without(alias.labels, "catch-all").join(", ") : "" - p.form-text: small.text-black-50= t("Labels must be a line-break/space/comma separated list with a maximum of 20 characters per label.") -if !domain || (domain && !domain.is_global) - .form-group.form-check - input#input-has-recipient-verification.form-check-input( - type="checkbox", - name="has_recipient_verification", - value="true", - checked=alias ? alias.has_recipient_verification : domain && domain.has_recipient_verification - ) - label.form-check-label(for="input-has-recipient-verification")= t("Requires recipients to click email verification link") - p.form-text: small.text-black-50= t("If you check this, then each email recipient will be required to click an email verification link in order for emails to flow through.") -.form-group.form-check - input#input-is-enabled.form-check-input( - type="checkbox", - name="is_enabled", - value="true", - checked=alias ? alias.is_enabled : true - ) - label.form-check-label(for="input-is-enabled")= t("Active") - p.form-text: small.text-black-50= t("If you uncheck this, then this email will be deactivated and no emails will flow through. However emails will NOT bounce until this alias is completely deleted.") diff --git a/app/views/my-account/domains/aliases/_table.pug b/app/views/my-account/domains/aliases/_table.pug deleted file mode 100644 index 9b84c0466..000000000 --- a/app/views/my-account/domains/aliases/_table.pug +++ /dev/null @@ -1,116 +0,0 @@ -include ../../../_sort-header -include ../../../_pagination - -- let numCols = 4; - -.table-responsive - table.table.table-hover.table-bordered - thead.thead-dark - tr - th(scope="col") - +sortHeader('name', 'Alias', '#table-aliases') - if domain.group === 'admin' && domain.plan === 'team' - - numCols++; - th(scope="col") - +sortHeader('user', 'Team Member', '#table-aliases') - if domain.aliases.find(alias => isSANB(alias.description) || (_.isArray(alias.labels) && alias.labels.length > 0)) - - numCols++; - th(scope="col") - +sortHeader('description', 'Description & Labels', '#table-aliases') - th.text-center(scope="col") - +sortHeader('is_enabled', 'Active', '#table-aliases') - th.align-middle(scope="col")= t("Recipients") - th.text-center.text-md-left.align-middle(scope="col")= t("Quick Links") - tbody - if domain.aliases.length === 0 - td.alert.alert-info(colspan=numCols)= t("No aliases exist for that search.") - else - each alias in domain.aliases - tr - td.align-middle - p.mb-0.markdown-body.ml-0.mr-0: code= `${alias.name}@${domain.name}` - if domain.group === 'admin' && domain.plan === 'team' - td.align-middle= alias.user.email - if domain.aliases.find(alias => isSANB(alias.description) || (_.isArray(alias.labels) && alias.labels.length > 0)) - td.align-middle - if isSANB(alias.description) - p.mb-0: small= alias.description - if Array.isArray(alias.labels) - ul.list-inline.mb-0 - each label in alias.labels - li.list-inline-item.h5 - span.badge.badge-pill.text-monospace.font-weight-normal.py-2.px-3( - class=label === "catch-all" ? "badge-success" : "badge-primary" - )= label === "catch-all" ? t("catch-all") : label - td.align-middle.text-center - button.btn.btn-link( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=alias.is_enabled ? t("Enabled") : t("Disabled"), - class=alias.is_enabled ? "text-success" : "text-danger" - ) - i.fa.fa-fw(class=alias.is_enabled ? "fa-check" : "fa-times") - td.align-middle - ul.list-inline.mb-0 - //- no recipient verification required - if domain.plan === 'free' || !alias.has_recipient_verification - each recipient in alias.recipients - li.list-inline-item.h5 - span.badge.badge-pill.badge-dark.text-monospace.font-weight-normal.py-2.px-3 - if validator.isEmail(recipient) || validator.isURL(recipient) - = recipient - else - = `${alias.name}@${recipient}` - else - each recipient in alias.recipients - li.list-inline-item.badge.badge-pill.badge-dark.text-monospace.font-weight-normal.py-2.px-3.mb-1 - if alias.verified_recipients.includes(recipient) - //- Verified - button.btn.btn-link.text-success( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=t("Verified") - ) - i.fa.fa-fw.fa-check - else if alias.pending_recipients.includes(recipient) - //- Sent and Pending - button.btn.btn-link.text-info( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=t("Pending Verification") - ) - i.fa.fa-fw.fa-info-circle - else - //- Not Sent (Queued) - button.btn.btn-link.text-warning( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=t("Queued Verification Email") - ) - i.fa.fa-fw.fa-exclamation-circle - if validator.isEmail(recipient) || validator.isURL(recipient) - = recipient - else - = `${alias.name}@${recipient}` - td.align-middle(style="width: 20%") - a.btn.btn-dark.mb-1( - href=`/my-account/domains/${domain.name}/aliases/${alias.id}` - ) - i.fa.fa-pencil - = " " - = t("Edit Alias") - form.ajax-form.confirm-prompt.d-inline-block( - action=l(`/my-account/domains/${domain.name}/aliases/${alias.id}`), - method="POST" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - input(type="hidden", name="_method", value="DELETE") - button.btn.btn-danger(type="submit") - i.fa.fa-trash - = " " - = t("Delete Alias") -+paginate('#table-aliases') diff --git a/app/views/my-account/domains/aliases/form.pug b/app/views/my-account/domains/aliases/form.pug deleted file mode 100644 index 5122985d4..000000000 --- a/app/views/my-account/domains/aliases/form.pug +++ /dev/null @@ -1,39 +0,0 @@ -extends ../../../layout - -block body - include ../_modal-domain - .container-fluid.pt-3 - .row - .col - include ../../../_breadcrumbs - .container - if domain && domain.group === 'admin' && !alias - .row.text-center.my-2 - .col - form.ajax-form.confirm-prompt.d-inline-block( - action=l(`/my-account/domains/${domain.name}/aliases/import`), - method="POST" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-lg.btn-dark.text-uppercase.font-weight-bold( - type="submit" - ) - = t("Import TXT Records") - = " " - i.fa.fa-cloud-download-alt - .row - .col - .card.border-dark - .card-body - form.ajax-form(action=ctx.path, method="POST") - include _form - button.btn.btn-success.btn-block.btn-lg(type="submit")= t("Continue") - if domain - .row.text-center.my-5 - .col - a.btn.btn-outline-secondary( - href=l(`/my-account/domains/${domain.name}/aliases`) - ) - i.fa.fa-angle-double-left - = " " - = t("Back to Aliases") diff --git a/app/views/my-account/domains/aliases/index.pug b/app/views/my-account/domains/aliases/index.pug deleted file mode 100644 index feeee5f1c..000000000 --- a/app/views/my-account/domains/aliases/index.pug +++ /dev/null @@ -1,59 +0,0 @@ -extends ../../../layout - -block body - include ../_modal-domain - - #modal-alias.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-alias-title", - aria-hidden="true", - data-backdrop="static" - ) - .modal-dialog.modal-lg(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-alias-title.modal-title.d-inline-block.ml-4= t("Add New Alias") - button.close(type="button", data-dismiss="modal", aria-label="Close") - span(aria-hidden="true") × - .modal-body - form.ajax-form( - action=l(`/my-account/domains/${domain.name}/aliases/new`), - method="POST" - ) - include _form - button.btn.btn-success.btn-block.btn-lg(type="submit")= t("Continue") - - .container-fluid.pt-3 - .row - .col - include ../../../_breadcrumbs - if domain.aliases.length === 0 && !ctx.query.q && !ctx.query.page - .alert.alert-info= t("No aliases exist yet") - else - form.ajax-form.table-ajax-form( - action=ctx.path, - method="GET", - data-table="#table-aliases", - data-search-params="q" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - .input-group.mb-3 - input#input-keyword-search.form-control( - type="text", - value=ctx.query.q ? ctx.query.q : "", - name="q", - placeholder=t("Search by alias name, description, or recipient") - ) - .input-group-append - button.btn.btn-success(type="submit")= t("Search") - #table-aliases - include ./_table - .row.text-center.my-5 - .col - a.btn.btn-outline-secondary( - href=l(domain.group === "admin" ? `/my-account/domains/${domain.name}` : "/my-account/domains") - ) - i.fa.fa-angle-double-left - = " " - = domain.group === "admin" ? t("Back to Domain") : t("Back to Domains") diff --git a/app/views/my-account/domains/billing.pug b/app/views/my-account/domains/billing.pug deleted file mode 100644 index 629226df1..000000000 --- a/app/views/my-account/domains/billing.pug +++ /dev/null @@ -1,186 +0,0 @@ -extends ../../layout - -block append scripts - //- Stripe - script(src="https://js.stripe.com/v3/") - //- PayPal - script( - src=`https://www.paypal.com/sdk/js?client-id=${config.paypal.clientID}&vault=true${config.env === 'production' ? '' : '&debug=true'}` - ) - script( - defer, - src=manifest("js/billing.js"), - integrity=manifest("js/billing.js", "integrity"), - crossorigin="anonymous" - ) - -block body - - const isMakePayment = ctx.pathWithoutLocale === "/my-account/billing/make-payment"; - #modal-pricing-video.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-pricing-video-title", - aria-hidden="true" - ) - .modal-dialog.modal-lg(role="document") - .modal-content - .modal-header.text-center.d-block - h1#modal-pricing-video-title.h4.modal-title.d-inline-block.ml-4= t("Learn about our plans") - button.close(type="button", data-dismiss="modal", aria-label="Close") - span(aria-hidden="true") × - .modal-body - .lazyframe( - title=t("Free Email Forwarding for Creators"), - style="width: 100%; height: 450px", - data-vendor="youtube_nocookie", - data-src="https://www.youtube-nocookie.com/embed/q7zfEpn2NdA?autoplay=0" - ) - .container-fluid.py-3 - .row - .col - include ../../_breadcrumbs - .row - .col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3.text-center - .card.border-dark - h2.card-header.bg-dark.text-white - if isMakePayment - = t("One-time payment") - else - if user.plan === 'team' && ctx.query.plan === 'enhanced_protection' - != t('Downgrade to %s', t(titleize(humanize(ctx.query.plan)))) - else - != t('Upgrade to %s', t(titleize(humanize(ctx.query.plan)))) - .card-body - p.lead - if isMakePayment - = t("Add credit to your account below.") - else - = t("Unlimited domains for one price.") - if !isMakePayment - button.btn.btn-dark.btn-lg.text-uppercase.font-weight-bold( - type="button", - data-toggle="modal", - data-target="#modal-pricing-video" - ) - i.fa.fa-play-circle - = " " - = t("Learn about plans") - hr - form#form-billing(action=ctx.path, method="POST") - input(type="hidden", name="_csrf", value=ctx.csrf) - - if !isMakePayment - input(type="hidden", name="plan", value=ctx.query.plan) - - h3.h4= t("Method") - .form-group - .form-check.form-check-inline - input#input-payment-method-credit-card.form-check-input( - type="radio", - checked, - required, - name="payment_method", - value="credit_card" - ) - label.form-check-label.lead( - for="input-payment-method-credit-card" - )= t("Debit/Credit Card") - .form-check.form-check-inline - input#input-payment-method-paypal.form-check-input( - type="radio", - required, - name="payment_method", - value="paypal" - ) - label.form-check-label.lead( - for="input-payment-method-paypal" - )= t("PayPal") - - //- Payment Type - //- TODO: default to last one used - if !isMakePayment - h3.h4= t("Payment Type") - .form-group - .form-check.form-check-inline - input#input-payment-type-subscription.form-check-input( - type="radio", - checked, - required, - name="payment_type", - value="subscription" - ) - label.form-check-label.lead( - for="input-payment-type-subscription" - )= t("Subscription") - .form-check.form-check-inline - input#input-payment-type-one-time.form-check-input( - type="radio", - required, - name="payment_type", - value="one-time" - ) - label.form-check-label.lead( - for="input-payment-type-one-time" - )= t("One-time") - - //- Length - //- TODO: default to last one used - h3.h4= t("Duration") - .form-group - select.form-control.form-control-lg.d-inline-block.w-auto( - name="payment_duration", - required - ) - - const plan = ctx.query.plan || user.plan; - if plan === 'enhanced_protection' - option(value="30d", selected)= t("1 month ($3 USD)") - option(value="60d")= t("2 months ($6 USD)") - option(value="90d")= t("3 months ($9 USD)") - option(value="180d")= t("6 months ($18 USD)") - option(value="1y")= t("1 year ($36 USD)") - //- since subscription is the default, we disable these - option( - value="2y", - data-no-subscription, - disabled=!isMakePayment - )= t("2 years ($72 USD)") - option( - value="3y", - data-no-subscription, - disabled=!isMakePayment - )= t("3 years ($108 USD)") - //- option(value='lifetime', data-no-subscription=true, disabled=!isMakePayment)= t('Lifetime ($144 USD)') - else if plan === 'team' - option(value="30d", selected)= t("1 month ($9 USD)") - option(value="60d")= t("2 months ($18 USD)") - option(value="90d")= t("3 months ($27 USD)") - option(value="180d")= t("6 months ($54 USD)") - option(value="1y")= t("1 year ($108 USD)") - option( - value="2y", - data-no-subscription, - disabled=!isMakePayment - )= t("2 years ($216 USD)") - option( - value="3y", - data-no-subscription, - disabled=!isMakePayment - )= t("3 years ($324 USD)") - //- option(value='lifetime', data-no-subscription=true, disabled=!isMakePayment)= t('Lifetime ($432 USD)') - - //- Continue (Stripe) - button#stripe-button-container.btn.btn-dark.btn-lg.btn-block( - type="submit" - ) - = t("Continue") - = " " - i.fa.fa-angle-double-right - - //- Continue (PayPal) - #paypal-button-container.d-none - - p.text-muted.pt-3.mb-0: small.text-muted= t("We accept all major currencies and card types.") - - .card-footer.text-muted.text-center= t("We'll automatically email you if you're late on a payment.") - if !isMakePayment - .mt-3.font-weight-bold.text-uppercase.text-decoration-underline= t("30-day money back guarantee") diff --git a/app/views/my-account/domains/index.pug b/app/views/my-account/domains/index.pug deleted file mode 100644 index 0f18e0a01..000000000 --- a/app/views/my-account/domains/index.pug +++ /dev/null @@ -1,54 +0,0 @@ -extends ../../layout - -block body - if domains.length > 0 - #modal-alias.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-alias-title", - aria-hidden="true", - data-backdrop="static" - ) - .modal-dialog.modal-lg(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-alias-title.modal-title.d-inline-block.ml-4= t("Add New Alias") - button.close( - type="button", - data-dismiss="modal", - aria-label="Close" - ) - span(aria-hidden="true") × - .modal-body - form.ajax-form(action=l("/my-account/aliases"), method="POST") - include aliases/_form - button.btn.btn-success.btn-block.btn-lg(type="submit")= t("Continue") - - include _modal-upgrade - include _modal-domain - - .container-fluid.py-3 - .row - .col - include ../../_breadcrumbs - if domains.length === 0 && !ctx.query.q && !ctx.query.page - .alert.alert-info= t("No domains exist yet.") - else - form.ajax-form.table-ajax-form( - action=ctx.path, - method="GET", - data-table="#table-domains", - data-search-params="q" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - .input-group.mb-3 - input#input-keyword-search.form-control( - type="text", - value=ctx.query.q ? ctx.query.q : "", - name="q", - placeholder=t("Search by domain name, alias name, or recipient") - ) - .input-group-append - button.btn.btn-success(type="submit")= t("Search") - #table-domains - include ./_table diff --git a/app/views/my-account/domains/new.pug b/app/views/my-account/domains/new.pug deleted file mode 100644 index 789637f94..000000000 --- a/app/views/my-account/domains/new.pug +++ /dev/null @@ -1,57 +0,0 @@ -extends ../../layout - -block body - .container-fluid.pt-3 - .row - .col - include ../../_breadcrumbs - .container - .row - .col-sm-12.col-md-12.col-lg-8.offset-lg-2 - form.ajax-form(action=ctx.path, method="POST") - input(type="hidden", name="_csrf", value=ctx.csrf) - if isSANB(ctx.query.plan) && ['free','enhanced_protection','team'].includes(ctx.query.plan) - input(type="hidden", name="plan", value=ctx.query.plan) - .form-group.floating-label - input#input-domain.form-control.form-control-lg( - required, - value=isSANB(ctx.query.domain) && isFQDN(ctx.query.domain) ? ctx.query.domain : "", - name="domain", - autocomplete="off", - placeholder="example.com", - autocorrect="off", - spellcheck="false", - autocapitalize="off", - type="text" - ) - label(for="input-domain")= t("Domain name") - if !isSANB(ctx.query.plan) || !['free','enhanced_protection','team'].includes(ctx.query.plan) - .form-check.form-group - input#input-enhanced-protection.form-check-input( - type="checkbox", - name="plan", - value="enhanced_protection", - checked - ) - label.form-check-label(for="input-enhanced-protection")!= t('Upgrade to Enhanced Protection', l("/pricing")) - if user.group === 'admin' - .form-group.form-check - input#input-is-global.form-check-input( - type="checkbox", - name="is_global", - value="true" - ) - label.form-check-label(for="input-is-global")= t("Global domain") - button.btn.btn-success.btn-block.btn-lg(type="submit")= t("Continue") - .alert.alert-warning.mt-3.text-center - ul.list-inline.mb-0 - li.list-inline-item= t("Don't have a domain name?") - li.list-inline-item - a.alert-link(href=l("/domain-registration"))= t("Register a domain") - if domains.length > 0 - .row.text-center.my-5 - .col - a.btn.btn-outline-secondary(href=l("/my-account/domains")) - i.fa.fa-angle-double-left - = " " - = t("Back to Domains") diff --git a/app/views/my-account/domains/retrieve.pug b/app/views/my-account/domains/retrieve.pug deleted file mode 100644 index 9a6ceb5b4..000000000 --- a/app/views/my-account/domains/retrieve.pug +++ /dev/null @@ -1,211 +0,0 @@ -extends ../../layout - -block body - include _modal-upgrade - if modalFAQTable - #modal-faq-table.modal.fade( - tabindex="-1", - role="dialog", - aria-labelledby="modal-faq-table-title", - aria-hidden="true" - ) - .modal-dialog.modal-lg(role="document") - .modal-content - .modal-header.text-center.d-block - h4#modal-faq-table-title.modal-title.d-inline-block.ml-4= t("DNS Management Pages") - button.close( - type="button", - data-dismiss="modal", - aria-label="Close" - ) - span(aria-hidden="true") × - .modal-body!= modalFAQTable - .container-fluid.pt-3 - .row - .col - include ../../_breadcrumbs - .py-5.text-center - h2.display-5.font-weight-bold.mb-3.text-uppercase= t("Verification Status") - if domain.has_mx_record && domain.has_txt_record - .alert.alert-success.d-inline-block.mx-2 - = t("You have successfully completed setup.") - = " " - = emoji("tada") - else - .alert.alert-danger.d-inline-block.mx-2 - = t('Follow steps below then "Verify Records"') - ul.list-inline - li.list-inline-item - button.btn.btn-link( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=domain.has_mx_record ? t("Verified") : t("Not Verified"), - class=domain.has_mx_record ? "text-success" : "text-danger" - ) - i.fa.fa-4x(class=domain.has_mx_record ? "fa-check" : "fa-times") - .text-monospace.text-uppercase= t("MX") - li.list-inline-item - button.btn.btn-link( - type="button", - data-placement="bottom", - data-toggle="tooltip", - data-title=domain.has_txt_record ? t("Verified") : t("Not Verified"), - class=domain.has_txt_record ? "text-success" : "text-danger" - ) - i.fa.fa-4x(class=domain.has_txt_record ? "fa-check" : "fa-times") - .text-monospace.text-uppercase= t("TXT") - if domain.group === 'admin' - form.ajax-form( - action=l(`/my-account/domains/${domain.name}/verify-records`), - method="POST" - ) - input(type="hidden", name="_csrf", value=ctx.csrf) - button.btn.btn-success.btn-lg(type="submit") - i.fa.fa-refresh - = " " - = t("Verify Records") - if !domain.has_mx_record - .container - .row - .col - .card.border-dark.mt-5 - h5.card-header.bg-dark.text-white= t("MX Records Required") - .card-body - p!= t("Set these MX records as the only MX records to your domain's DNS:") - .table-responsive - table.table.table-striped.table-hover.mt-2 - thead.thead-dark - tr - th Name/Host/Alias - th.text-center TTL - th Type - th Priority - th Value - tbody - tr - td: em "@", ".", or blank - td.text-center 3600 - td MX - td 10 - td: code mx1.forwardemail.net - tr - td: em "@", ".", or blank - td.text-center 3600 - td MX - td 10 - td: code mx2.forwardemail.net - if modalFAQTable - .text-center.mt-2 - button.btn.btn-danger.btn-lg( - type="button", - data-toggle="modal", - data-target="#modal-faq-table" - )= t("Need help?") - if domain.plan === 'free' && !domain.has_txt_record - .container - .row - .col - .card.border-dark.mt-5 - h5.card-header.bg-dark.text-white= t("TXT Record Required") - .card-body - p!= t("Add this TXT record to your domain's DNS for a global catch-all:") - .input-group - input#verification-record.form-control( - type="text", - readonly, - value=`${config.recordPrefix}=${user.email}` - ) - .input-group-append - button.btn.btn-primary( - type="button", - data-toggle="clipboard", - data-clipboard-target="#verification-record" - ) - i.fa.fa-clipboard - = " " - = t("Copy") - p.text-center.text-muted.py-2: small!= t('Use "@", ".", or blank for the name/host/alias value. Read our FAQ section for more options', l(`/faq?domain=${domain.name}#how-do-i-get-started-and-set-up-email-forwarding`)) - if modalFAQTable - .text-center.mt-3 - button.btn.btn-danger.btn-lg( - type="button", - data-toggle="modal", - data-target="#modal-faq-table" - )= t("Need help?") - else if domain.plan !== 'free' && !domain.has_txt_record - .container - .row - .col - .card.border-dark.mt-5 - h5.card-header.bg-dark.text-white= t("TXT Record Required") - .card-body - p!= t("Add your Enhanced Protection Verification TXT record to your domain's DNS:") - .input-group - input#verification-record.form-control( - type="text", - readonly, - value=`${config.recordPrefix}-site-verification=${domain.verification_record}` - ) - .input-group-append - button.btn.btn-primary( - type="button", - data-toggle="clipboard", - data-clipboard-target="#verification-record" - ) - i.fa.fa-clipboard - = " " - = t("Copy") - p.text-center.text-muted.py-2: small!= t('Use "@", ".", or blank for the name/host/alias value. You must remove any existing "%s=" records.', config.recordPrefix) - if modalFAQTable - .text-center.mt-3 - button.btn.btn-danger.btn-lg( - type="button", - data-toggle="modal", - data-target="#modal-faq-table" - )= t("Need help?") - .container-fluid.py-5 - .position-relative - .d-flex.flex-row.align-content-between.flex-wrap.align-items-stretch - .col-12.d-flex - .p-3.p-md-5.m-2.mt-lg-0.rounded-lg.border.border-dark.bg-white.border-dark-50.border.d-flex.flex-column.flex-grow-1.align-items-start - h2.display-5.font-weight-bold.mb-3= t("1. Set up your domain for email forwarding") - p.lead.mb-3= t("Do you need to configure (or re-configure) this domain name for email forwarding?") - a.mt-auto.stretched-link.btn.btn-lg.btn-outline-dark( - href=l(`/faq?domain=${domain.name}#how-do-i-get-started-and-set-up-email-forwarding`), - target="_blank", - rel="noopener noreferrer" - ) - = t("Configure your domain") - = " " - i.fa.fa-angle-double-right - .col-12.d-flex - .p-3.p-md-5.mt-5.mt-lg-0.mt-md-2.m-2.rounded-lg.border.border-dark.bg-white.border-dark-50.border.d-flex.flex-column.flex-grow-1.align-items-start - h2.display-5.font-weight-bold.mb-3= t("2. Send emails with your domain using Gmail (optional)") - p.lead.mb-3!= t("Follow our simple guide for sending and receiving emails with your domain, directly inside of Gmail.") - a.mt-auto.stretched-link.btn.btn-lg.btn-outline-dark( - href=l(`/faq?domain=${domain.name}#how-to-send-mail-as-using-gmail`), - target="_blank", - rel="noopener noreferrer" - ) - = t("Setup your Gmail") - = " " - i.fa.fa-angle-double-right - .col-12.d-flex - .p-3.p-md-5.mt-5.mt-lg-0.mt-md-2.m-2.rounded-lg.border.border-dark.bg-white.border-dark-50.border.d-flex.flex-column.flex-grow-1.align-items-start - h2.display-5.font-weight-bold.mb-3= t("3. Still need help with something?") - p.lead.mb-3!= t("We are here to answer your questions, but please be sure to read our FAQ section first.") - a.mt-auto.stretched-link.btn.btn-lg.btn-outline-dark( - href=l("/help"), - role="button", - data-target="#modal-help" - ) - = t("Ask us a question") - = " " - i.fa.fa-angle-double-right - .row.text-center.mt-5 - .col - a.btn.btn-outline-secondary(href=l("/my-account/domains")) - i.fa.fa-angle-double-left - = " " - = t("Back to Domains")