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
4 changes: 3 additions & 1 deletion app/controllers/web/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const emails = require('./emails');
const inquiries = require('./inquiries');
const payments = require('./payments');
const jobs = require('./jobs');
const spam = require('./spam');

module.exports = {
analytics,
Expand All @@ -26,5 +27,6 @@ module.exports = {
emails,
inquiries,
payments,
jobs
jobs,
spam
};
185 changes: 185 additions & 0 deletions app/controllers/web/admin/spam.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Copyright (c) Forward Email LLC
* SPDX-License-Identifier: BUSL-1.1
*/

const paginate = require('koa-ctx-paginate');
const dayjs = require('dayjs-with-plugins');

const getMongoQuery = require('#helpers/get-mongo-query');
const { Logs } = require('#models');

const MAX_TIME_MS = 10000;

// Cap count at 10,000 to improve performance on large collections
const MAX_COUNT_LIMIT = 10_000;

async function list(ctx) {
const userQuery = getMongoQuery(ctx);

// Build base query: always filter to spam bounce category
const query = { ...userQuery, bounce_category: 'spam' };

// Parse filter params
const { ip, hostname, start_date, end_date } = ctx.query;

if (ip) query['meta.app.ip'] = ip;
if (hostname) query['meta.app.hostname'] = hostname;

// Date range filter (defaults to last 7 days)
const endDate = end_date ? dayjs(end_date).endOf('day').toDate() : new Date();
const startDate = start_date
? dayjs(start_date).startOf('day').toDate()
: dayjs(endDate).subtract(7, 'day').toDate();

query.created_at = { $gte: startDate, $lte: endDate };

// Determine time bucket format based on range span
const rangeDays = dayjs(endDate).diff(dayjs(startDate), 'day');
const dateFormat = rangeDays <= 3 ? '%Y-%m-%dT%H:00' : '%Y-%m-%d';

// Queries for 24h/7d/30d summary counts
const now = new Date();
const baseMatch = { bounce_category: 'spam' };
if (ip) baseMatch['meta.app.ip'] = ip;
if (hostname) baseMatch['meta.app.hostname'] = hostname;

const [
logs,
itemCount,
timeline,
topIPs,
topErrors,
topHostnames,
count24h,
count7d,
count30d
] = await Promise.all([
// Paginated logs
// eslint-disable-next-line unicorn/no-array-callback-reference
Logs.find(query)
.hint({ bounce_category: 1, domains: 1, created_at: 1 })
.limit(ctx.query.limit)
.skip(ctx.paginate.skip)
.sort(ctx.query.sort || '-created_at')
.select(
'created_at meta.app.ip meta.app.hostname err.message err.responseCode err.statusCode err.status message domains'
)
.lean()
.maxTimeMS(MAX_TIME_MS)
.exec(),
// Item count
Logs.countDocuments(query, {
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}),
// Spam over time
Logs.aggregate([
{ $match: query },
{
$group: {
_id: { $dateToString: { format: dateFormat, date: '$created_at' } },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]).option({
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}),
// Top IPs
Logs.aggregate([
{ $match: query },
{ $group: { _id: '$meta.app.ip', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 10 }
]).option({
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}),
// Top error messages
Logs.aggregate([
{ $match: query },
{ $group: { _id: '$err.message', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 10 }
]).option({
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}),
// Top hostnames
Logs.aggregate([
{ $match: query },
{ $group: { _id: '$meta.app.hostname', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 10 }
]).option({
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}),
// 24h count
Logs.countDocuments(
{
...baseMatch,
created_at: { $gte: dayjs(now).subtract(24, 'hour').toDate() }
},
{
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}
),
// 7d count
Logs.countDocuments(
{
...baseMatch,
created_at: { $gte: dayjs(now).subtract(7, 'day').toDate() }
},
{
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}
),
// 30d count
Logs.countDocuments(
{
...baseMatch,
created_at: { $gte: dayjs(now).subtract(30, 'day').toDate() }
},
{
hint: { bounce_category: 1, domains: 1, created_at: 1 },
maxTimeMS: MAX_TIME_MS
}
)
]);

// Cap the item count to prevent UI issues with very large result sets
const cappedItemCount = Math.min(itemCount, MAX_COUNT_LIMIT);
const pageCount = Math.ceil(cappedItemCount / ctx.query.limit);

const renderData = {
logs,
pageCount,
itemCount: cappedItemCount,
timeline,
topIPs,
topErrors,
topHostnames,
count24h,
count7d,
count30d,
startDate: dayjs(startDate).format('YYYY-MM-DD'),
endDate: dayjs(endDate).format('YYYY-MM-DD'),
filterIP: ip || '',
filterHostname: hostname || '',
pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page)
};

if (ctx.accepts('html')) return ctx.render('admin/spam', renderData);

const table = await ctx.render('admin/spam/_table', renderData);
ctx.body = { table };
}

module.exports = {
list
};
67 changes: 67 additions & 0 deletions app/views/admin/spam/_table.pug
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
include ../../_sort-header
include ../../_pagination

table.table.table-hover.table-bordered.table-sm
thead.thead-dark
tr
th(scope="col")
+sortHeader('created_at', 'Created', '#table-spam')
th(scope="col")
+sortHeader('meta.app.ip', 'IP', '#table-spam')
th(scope="col")
+sortHeader('meta.app.hostname', 'Hostname', '#table-spam')
th.align-middle(scope="col")= t("Error Message")
th.align-middle.text-center(scope="col")= t("Response Code")
th.align-middle(scope="col")= t("Domain")
th.text-center.align-middle(scope="col")= t("Actions")
tbody
if logs.length === 0
tr
td.alert.alert-info(colspan="7")
= t("No spam logs found for that search.")
else
each log in logs
tr
td.align-middle.text-center.dayjs(
data-time=new Date(log.created_at).getTime()
)
= dayjs(log.created_at).tz(user.timezone === 'Etc/Unknown' ? 'UTC' : user.timezone).format("M/D/YY h:mm A z")
td.align-middle
if log.meta && log.meta.app && log.meta.app.ip
code= log.meta.app.ip
td.align-middle
if log.meta && log.meta.app && log.meta.app.hostname
code= log.meta.app.hostname
td.align-middle.small
if log.err && log.err.message
!= ansiHTML(log.err.message)
else
!= ansiHTML(log.message)
td.align-middle.text-center
- let statusCode;
if log.err && log.err.responseCode
- statusCode = log.err.responseCode;
else if log.err && log.err.statusCode
- statusCode = log.err.statusCode;
else if log.err && log.err.status
- statusCode = log.err.status;

if statusCode
- let badgeClass = "badge-success";
if statusCode >= 500
- badgeClass = "badge-danger";
else if statusCode >= 400
- badgeClass = "badge-warning";
else if statusCode >= 300
- badgeClass = "badge-primary";
span.badge.badge-pill(class=badgeClass)= statusCode
td.align-middle
if log.domains && log.domains.length > 0
each domain in log.domains
span.badge.badge-secondary.mr-1= domain
td.align-middle.text-center
a.btn.btn-dark.btn-sm(
href=l(`/admin/logs/${log.id}`),
target="_blank"
): i.fa.fa-search
+paginate('#table-spam')
Loading
Loading