Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/controllers/api/v1/enforce-enterprise-plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copyright (c) Forward Email LLC
* SPDX-License-Identifier: BUSL-1.1
*/

const Boom = require('@hapi/boom');
const config = require('#config');

async function enforceEnterprisePlan(ctx, next) {
if (!ctx.isAuthenticated())
throw Boom.unauthorized(ctx.translateError('LOGIN_REQUIRED'));

if (config.isSelfHosted) return next();

// Check if user or domain has enterprise plan
const hasEnterprisePlan =
ctx.state.user.plan === 'enterprise' ||
ctx.state?.domain?.plan === 'enterprise';

if (!hasEnterprisePlan)
throw Boom.paymentRequired(
ctx.translateError(
'ENTERPRISE_PLAN_REQUIRED',
ctx.state.l('/my-account/billing/upgrade?plan=enterprise')
)
);

return next();
}

module.exports = enforceEnterprisePlan;
3 changes: 2 additions & 1 deletion app/controllers/api/v1/enforce-paid-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ async function enforcePaidPlan(ctx, next) {
// if the user is a member of a team plan and in the admin group, continue
if (
ctx.state?.domain?.group === 'admin' &&
ctx.state?.domain?.plan === 'team'
(ctx.state?.domain?.plan === 'team' ||
ctx.state?.domain?.plan === 'enterprise')
)
return next();

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const calendars = require('./calendars');
const contacts = require('./contacts');
const domains = require('./domains');
const enforcePaidPlan = require('./enforce-paid-plan');
const enforceEnterprisePlan = require('./enforce-enterprise-plan');
const folders = require('./folders');
const inquiries = require('./inquiries');
const log = require('./log');
Expand All @@ -35,6 +36,7 @@ module.exports = {
contacts,
domains,
enforcePaidPlan,
enforceEnterprisePlan,
folders,
inquiries,
log,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/port.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function port(ctx) {
const domain = await Domains.findOne({
name: ctx.query.domain,
verification_record: verifications[0],
plan: { $in: ['enhanced_protection', 'team'] }
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] }
})
.lean()
.exec();
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/api/v1/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ async function processEvent(ctx, event) {
group: 'admin'
}
},
plan: { $in: ['enhanced_protection', 'team'] },
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
has_txt_record: true
});

Expand Down Expand Up @@ -253,7 +253,7 @@ async function processEvent(ctx, event) {

if (
!isSANB(productToPlan) ||
!['team', 'enhanced_protection'].includes(productToPlan)
!['team', 'enhanced_protection', 'enterprise'].includes(productToPlan)
)
throw new Error('Plan was not valid');

Expand Down Expand Up @@ -523,7 +523,7 @@ async function processEvent(ctx, event) {
group: 'admin'
}
},
plan: { $in: ['enhanced_protection', 'team'] },
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
has_txt_record: true
});

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/web/admin/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async function getGrowthChart() {
const docs = await Users.aggregate([
{
$match: {
plan: { $in: ['enhanced_protection', 'team'] },
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
created_at: {
$gte: dayjs()
.subtract(1, 'day')
Expand Down
30 changes: 16 additions & 14 deletions app/controllers/web/admin/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,19 @@ async function list(ctx) {
);
}

// Search in enum/categorical fields with exact and partial matches
// Search in enum/categorical fields with exact matches (faster than regex)
for (const field of PAYMENT_ENUM_FIELDS) {
// Try exact match first (case-insensitive)
const exactMatch = ctx.query.q.toLowerCase();
query.$or.push(
{ [field]: { $regex: ctx.query.q, $options: 'i' } },
{ [field]: { $regex: _.escapeRegExp(ctx.query.q), $options: 'i' } }
{ [field]: exactMatch },
{ [field]: { $regex: ctx.query.q, $options: 'i' } }
);
}

// Search by user email
// Search by user email - optimized query
const users = await Users.find({
$or: [
{ email: { $regex: ctx.query.q, $options: 'i' } },
{ email: { $regex: _.escapeRegExp(ctx.query.q), $options: 'i' } }
]
email: { $regex: ctx.query.q, $options: 'i' }
})
.select('_id')
.lean()
Expand Down Expand Up @@ -99,13 +98,15 @@ async function list(ctx) {
from: 'users',
localField: 'user',
foreignField: '_id',
pipeline: [
{ $project: { email: 1, plan: 1 } } // Only select needed fields
],
as: 'user'
}
},
{
$unwind: {
path: '$user',
preserveNullAndEmptyArrays: true
$addFields: {
user: { $arrayElemAt: ['$user', 0] } // More efficient than $unwind
}
},
{
Expand Down Expand Up @@ -258,11 +259,12 @@ async function freeCredit(ctx) {
? `${durationMapping[0]} ${durationMapping[1]}`
: dayjs.duration(duration, 'milliseconds').humanize();

const message = ctx.translate('FREE_CREDIT_GRANTED', {
const message = ctx.translate(
'FREE_CREDIT_GRANTED',
email,
plan,
duration: durationFormatted
});
durationFormatted
);

if (ctx.accepts('html')) {
ctx.flash('custom', {
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/web/my-account/create-domain-billing.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async function createDomainBilling(ctx) {
// plan
if (
!isSANB(plan) ||
!['free', 'enhanced_protection', 'team'].includes(plan)
!['free', 'enhanced_protection', 'team', 'enterprise'].includes(plan)
) {
throw Boom.badRequest(ctx.translateError('INVALID_PLAN'));
}
Expand Down
11 changes: 11 additions & 0 deletions app/controllers/web/my-account/ensure-enterprise-plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Copyright (c) Forward Email LLC
* SPDX-License-Identifier: BUSL-1.1
*/

function ensureEnterprisePlan(ctx, next) {
ctx.state.isEnterprisePlanRequired = ctx.state.domain.plan !== 'enterprise';
return next();
}

module.exports = ensureEnterprisePlan;
3 changes: 2 additions & 1 deletion app/controllers/web/my-account/ensure-upgraded-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ function ensureUpgradedPlan(ctx, next) {

if (
(!ctx.state.domain && ctx.state.user.plan !== 'free') ||
ctx.state?.domain?.plan === 'team'
ctx.state?.domain?.plan === 'team' ||
ctx.state?.domain?.plan === 'enterprise'
)
return next();

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/web/my-account/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 ensureEnterprisePlan = require('./ensure-enterprise-plan');
const ensureUpgradedPlan = require('./ensure-upgraded-plan');
const ensurePaidToDate = require('./ensure-paid-to-date');
const importAliases = require('./import-aliases');
Expand Down Expand Up @@ -83,6 +84,7 @@ module.exports = {
ensureDomainAdmin,
ensureNotBanned,
ensureTeamPlan,
ensureEnterprisePlan,
ensureUpgradedPlan,
ensurePaidToDate,
importAliases,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/web/my-account/remove-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async function removeDomain(ctx, next) {
group: 'admin'
}
},
plan: { $in: ['enhanced_protection', 'team'] }
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] }
});
if (count === 0) {
ctx.logger.info(`updating to free plan`);
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/web/my-account/remove.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function remove(ctx) {
// check that we're not an admin of any team domains
const domainsWithOtherAdmins = ctx.state.domains.some(
(d) =>
d.plan === 'team' &&
(d.plan === 'team' || d.plan === 'enterprise') &&
d.group === 'admin' &&
d.members.some(
(m) => m.group === 'admin' && m.user.id !== ctx.state.user.id
Expand Down
20 changes: 14 additions & 6 deletions app/controllers/web/my-account/retrieve-domain-billing.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,9 @@ async function retrieveDomainBilling(ctx) {
try {
if (
!isSANB(ctx.query.plan) ||
!['free', 'enhanced_protection', 'team'].includes(ctx.query.plan)
!['free', 'enhanced_protection', 'team', 'enterprise'].includes(
ctx.query.plan
)
)
throw Boom.badRequest(ctx.translateError('INVALID_PLAN'));

Expand Down Expand Up @@ -261,7 +263,11 @@ async function retrieveDomainBilling(ctx) {

// determine what plans are required
const validPlans =
domain.plan === 'team' ? ['team'] : ['enhanced_protection', 'team'];
domain.plan === 'team'
? ['team']
: domain.plan === 'enterprise'
? ['enterprise']
: ['enhanced_protection', 'team', 'enterprise'];
let isValid = false;

for (const member of domain.members) {
Expand Down Expand Up @@ -337,7 +343,9 @@ async function retrieveDomainBilling(ctx) {
isMakePayment ||
(ctx.query.plan === 'team' && ctx.state.user.plan !== 'team') ||
(ctx.query.plan === 'enhanced_protection' &&
!['team', 'enhanced_protection'].includes(ctx.state.user.plan)))
!['team', 'enhanced_protection', 'enterprise'].includes(
ctx.state.user.plan
)))
) {
//
// if the user is switching between plans and had conversion credit
Expand Down Expand Up @@ -490,7 +498,7 @@ async function retrieveDomainBilling(ctx) {

if (
!isSANB(productToPlan) ||
!['team', 'enhanced_protection'].includes(productToPlan)
!['team', 'enhanced_protection', 'enterprise'].includes(productToPlan)
)
throw ctx.translateError('UNKNOWN_ERROR');

Expand Down Expand Up @@ -944,7 +952,7 @@ async function retrieveDomainBilling(ctx) {

// validate plans matched up
if (
!['team', 'enhanced_protection'].includes(
!['team', 'enhanced_protection', 'enterprise'].includes(
body.purchase_units[0].custom_id.toLowerCase()
) ||
body.purchase_units[0].custom_id.toLowerCase() !== ctx.query.plan
Expand Down Expand Up @@ -1462,7 +1470,7 @@ ${safeStringify(parseErr(err), null, 2)}</code></pre>`
group: 'admin'
}
},
plan: { $in: ['enhanced_protection', 'team'] }
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] }
});
if (count === 0) {
ctx.logger.info(`updating to free plan`);
Expand Down
10 changes: 8 additions & 2 deletions app/controllers/web/my-account/validate-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ async function validateDomain(ctx, next) {

if (isSANB(ctx.request.body.plan)) {
if (
!['free', 'enhanced_protection', 'team'].includes(ctx.request.body.plan)
!['free', 'enhanced_protection', 'team', 'enterprise'].includes(
ctx.request.body.plan
)
)
throw Boom.badRequest(ctx.translateError('INVALID_PLAN'));
} else {
Expand Down Expand Up @@ -163,7 +165,11 @@ async function validateDomain(ctx, next) {
if (isSANB(ctx.request.body.plan)) {
switch (ctx.request.body.plan) {
case 'enhanced_protection': {
if (!['enhanced_protection', 'team'].includes(ctx.state.user.plan)) {
if (
!['enhanced_protection', 'team', 'enterprise'].includes(
ctx.state.user.plan
)
) {
ctx.request.body.plan = 'free';
ctx.state.redirectTo = ctx.state.l(
`/my-account/domains/${punycode.toASCII(
Expand Down
20 changes: 14 additions & 6 deletions app/models/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -827,9 +827,13 @@ Domains.pre('validate', async function (next) {
return true;
}

if (domain.plan === 'enterprise' && user.plan === 'enterprise') {
return true;
}

if (
domain.plan === 'enhanced_protection' &&
['enhanced_protection', 'team'].includes(user.plan)
['enhanced_protection', 'team', 'enterprise'].includes(user.plan)
) {
return true;
}
Expand Down Expand Up @@ -1099,7 +1103,7 @@ Domains.pre('save', function (next) {

// Prevent members from existing on domains after plan changes
Domains.pre('save', function (next) {
if (this.plan === 'team') {
if (this.plan === 'team' || this.plan === 'enterprise') {
return next();
}

Expand Down Expand Up @@ -2274,15 +2278,15 @@ async function getTxtAddresses(
Domains.statics.getTxtAddresses = getTxtAddresses;

async function ensureUserHasValidPlan(user, locale) {
// If the user was on the team plan
// If the user was on the team or enterprise plan
// then all of their domains would be valid
if (user.plan === 'team') {
if (user.plan === 'team' || user.plan === 'enterprise') {
return;
}

// Get all domains associated with this user
const domains = await this.find({
plan: { $in: ['enhanced_protection', 'team'] },
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
'members.user': user._id
})
.select('name plan members.user members.group')
Expand All @@ -2300,7 +2304,11 @@ async function ensureUserHasValidPlan(user, locale) {
for (const domain of domains) {
// Determine what plans are required
const validPlans =
domain.plan === 'team' ? ['team'] : ['enhanced_protection', 'team'];
domain.plan === 'team'
? ['team']
: domain.plan === 'enterprise'
? ['enterprise']
: ['enhanced_protection', 'team', 'enterprise'];
let isValid = false;

for (const member of domain.members) {
Expand Down
6 changes: 5 additions & 1 deletion app/models/emails.js
Original file line number Diff line number Diff line change
Expand Up @@ -1392,7 +1392,11 @@ Emails.statics.queue = async function (
// validate that at least one paying, non-banned admin on >= same plan without expiration
//
const validPlans =
domain.plan === 'team' ? ['team'] : ['team', 'enhanced_protection'];
domain.plan === 'team'
? ['team']
: domain.plan === 'enterprise'
? ['enterprise']
: ['team', 'enhanced_protection', 'enterprise'];

if (
!domain.members.some(
Expand Down
2 changes: 1 addition & 1 deletion app/models/logs.js
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,7 @@ async function parseLog(log) {
const domain = await Domains.findOne({
name,
verification_record: verifications[0],
plan: { $in: ['enhanced_protection', 'team'] }
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] }
});

if (!domain) return;
Expand Down
Loading
Loading