Merge pull request #2559 from bluewave-labs/feat/super-simple-queue
feat: super simple queue
This commit is contained in:
8 files changed
+1463
-915
No files matched your search
+22
-4
@@ -53,6 +53,9 @@ import { Queue, Worker } from "bullmq";
|
||||
import PulseQueue from "./service/PulseQueue/PulseQueue.js";
|
||||
import PulseQueueHelper from "./service/PulseQueue/PulseQueueHelper.js";
|
||||
|
||||
import SuperSimpleQueue from "./service/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
import SuperSimpleQueueHelper from "./service/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
|
||||
//Network service and dependencies
|
||||
import NetworkService from "./service/networkService.js";
|
||||
import axios from "axios";
|
||||
@@ -191,23 +194,38 @@ const startApp = async () => {
|
||||
// stringService,
|
||||
// });
|
||||
|
||||
const pulseQueueHelper = new PulseQueueHelper({
|
||||
// const pulseQueueHelper = new PulseQueueHelper({
|
||||
// db,
|
||||
// logger,
|
||||
// networkService,
|
||||
// statusService,
|
||||
// notificationService,
|
||||
// });
|
||||
// const pulseQueue = await PulseQueue.create({
|
||||
// appSettings,
|
||||
// db,
|
||||
// pulseQueueHelper,
|
||||
// logger,
|
||||
// });
|
||||
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
|
||||
db,
|
||||
logger,
|
||||
networkService,
|
||||
statusService,
|
||||
notificationService,
|
||||
});
|
||||
const pulseQueue = await PulseQueue.create({
|
||||
|
||||
const superSimpleQueue = await SuperSimpleQueue.create({
|
||||
appSettings,
|
||||
db,
|
||||
pulseQueueHelper,
|
||||
logger,
|
||||
helper: superSimpleQueueHelper,
|
||||
});
|
||||
|
||||
// Register services
|
||||
// ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue);
|
||||
ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue);
|
||||
// ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue);
|
||||
ServiceRegistry.register(JobQueue.SERVICE_NAME, superSimpleQueue);
|
||||
ServiceRegistry.register(MongoDB.SERVICE_NAME, db);
|
||||
ServiceRegistry.register(SettingsService.SERVICE_NAME, settingsService);
|
||||
ServiceRegistry.register(EmailService.SERVICE_NAME, emailService);
|
||||
|
||||
+1061
-743
File diff suppressed because it is too large.
Load diff
@@ -40,6 +40,7 @@
|
||||
"ping": "0.4.4",
|
||||
"sharp": "0.33.5",
|
||||
"ssl-checker": "2.0.10",
|
||||
"super-simple-scheduler": "1.3.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"winston": "^3.13.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import Scheduler from "super-simple-scheduler";
|
||||
const SERVICE_NAME = "SuperSimpleQueue";
|
||||
|
||||
class SuperSimpleQueue {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
constructor({ appSettings, db, logger, helper }) {
|
||||
this.appSettings = appSettings;
|
||||
this.db = db;
|
||||
this.logger = logger;
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
static async create({ appSettings, db, logger, helper }) {
|
||||
const instance = new SuperSimpleQueue({ appSettings, db, logger, helper });
|
||||
await instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
try {
|
||||
this.scheduler = new Scheduler({
|
||||
// storeType: "mongo",
|
||||
// storeType: "redis",
|
||||
logLevel: "debug",
|
||||
debug: true,
|
||||
dbUri: this.appSettings.dbConnectionString,
|
||||
});
|
||||
this.scheduler.start();
|
||||
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
|
||||
const monitors = await this.db.getAllMonitors();
|
||||
for (const monitor of monitors) {
|
||||
await this.addJob(monitor._id, monitor);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error({
|
||||
message: "Failed to initialize SuperSimpleQueue",
|
||||
service: SERVICE_NAME,
|
||||
method: "init",
|
||||
details: error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
addJob = async (monitorId, monitor) => {
|
||||
this.scheduler.addJob({
|
||||
id: monitorId.toString(),
|
||||
template: "monitor-job",
|
||||
repeat: monitor.interval,
|
||||
data: monitor.toObject(),
|
||||
});
|
||||
};
|
||||
|
||||
deleteJob = async (monitor) => {
|
||||
this.scheduler.removeJob(monitor._id.toString());
|
||||
};
|
||||
|
||||
pauseJob = async (monitor) => {
|
||||
const result = this.scheduler.pauseJob(monitor._id.toString());
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
}
|
||||
this.logger.debug({
|
||||
message: `Paused monitor ${monitor._id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "pauseJob",
|
||||
});
|
||||
};
|
||||
|
||||
resumeJob = async (monitor) => {
|
||||
const result = this.scheduler.resumeJob(monitor._id.toString());
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
}
|
||||
this.logger.debug({
|
||||
message: `Resumed monitor ${monitor._id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "resumeJob",
|
||||
});
|
||||
};
|
||||
|
||||
updateJob = async (monitor) => {
|
||||
this.scheduler.updateJob(monitor._id.toString(), monitor.interval);
|
||||
};
|
||||
|
||||
shutdown = async () => {
|
||||
this.scheduler.stop();
|
||||
};
|
||||
|
||||
getMetrics = async () => {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
const metrics = jobs.reduce(
|
||||
(acc, job) => {
|
||||
acc.totalRuns += job.runCount || 0;
|
||||
acc.totalFailures += job.failCount || 0;
|
||||
acc.jobs++;
|
||||
if (job.failCount > 0 && job.lastFailedAt >= job.lsatRunAt) {
|
||||
acc.failingJobs++;
|
||||
}
|
||||
|
||||
if (job.lockedAt) {
|
||||
acc.activeJobs++;
|
||||
}
|
||||
|
||||
if (job.failCount > 0) {
|
||||
acc.jobsWithFailures.push({
|
||||
monitorId: job.id,
|
||||
monitorUrl: job?.data?.url || null,
|
||||
monitorType: job?.data?.type || null,
|
||||
failedAt: job.lastFailedAt,
|
||||
failCount: job.failCount,
|
||||
failReason: job.lastFailReason,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
jobs: 0,
|
||||
activeJobs: 0,
|
||||
failingJobs: 0,
|
||||
jobsWithFailures: [],
|
||||
totalRuns: 0,
|
||||
totalFailures: 0,
|
||||
}
|
||||
);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
getJobs = async () => {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
return jobs.map((job) => {
|
||||
return {
|
||||
monitorId: job.id,
|
||||
monitorUrl: job?.data?.url || null,
|
||||
monitorType: job?.data?.type || null,
|
||||
active: job.active,
|
||||
lockedAt: job.lockedAt,
|
||||
runCount: job.runCount || 0,
|
||||
failCount: job.failCount || 0,
|
||||
failReason: job.lastFailReason,
|
||||
lastRunAt: job.lastRunAt,
|
||||
lastFinishedAt: job.lastFinishedAt,
|
||||
lastRunTook: job.lockedAt ? null : job.lastFinishedAt - job.lastRunAt,
|
||||
lastFailedAt: job.lastFailedAt,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
flushQueues = async () => {
|
||||
const stopRes = this.scheduler.stop();
|
||||
const flushRes = this.scheduler.flushJobs();
|
||||
const initRes = await this.init();
|
||||
return {
|
||||
success: stopRes && flushRes && initRes,
|
||||
};
|
||||
};
|
||||
|
||||
obliterate = async () => {
|
||||
console.log("obliterate not implemented");
|
||||
};
|
||||
}
|
||||
|
||||
export default SuperSimpleQueue;
|
||||
@@ -0,0 +1,99 @@
|
||||
const SERVICE_NAME = "SuperSimpleQueueHelper";
|
||||
|
||||
class SuperSimpleQueueHelper {
|
||||
constructor({ db, logger, networkService, statusService, notificationService }) {
|
||||
this.db = db;
|
||||
this.logger = logger;
|
||||
this.networkService = networkService;
|
||||
this.statusService = statusService;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
getMonitorJob = () => {
|
||||
return async (monitor) => {
|
||||
try {
|
||||
const monitorId = monitor._id;
|
||||
if (!monitorId) {
|
||||
throw new Error("No monitor id");
|
||||
}
|
||||
|
||||
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId);
|
||||
if (maintenanceWindowActive) {
|
||||
this.logger.info({
|
||||
message: `Monitor ${monitorId} is in maintenance window`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const networkResponse = await this.networkService.getStatus(monitor);
|
||||
if (!networkResponse) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
|
||||
const {
|
||||
monitor: updatedMonitor,
|
||||
statusChanged,
|
||||
prevStatus,
|
||||
} = await this.statusService.updateStatus(networkResponse);
|
||||
|
||||
this.notificationService
|
||||
.handleNotifications({
|
||||
...networkResponse,
|
||||
monitor: updatedMonitor,
|
||||
prevStatus,
|
||||
statusChanged,
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error({
|
||||
message: error.message,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
details: `Error sending notifications for job ${monitor._id}: ${error.message}`,
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
message: error.message,
|
||||
service: error.service || SERVICE_NAME,
|
||||
method: error.method || "getMonitorJob",
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
async isInMaintenanceWindow(monitorId) {
|
||||
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId);
|
||||
// Check for active maintenance window:
|
||||
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
|
||||
if (window.active) {
|
||||
const start = new Date(window.start);
|
||||
const end = new Date(window.end);
|
||||
const now = new Date();
|
||||
const repeatInterval = window.repeat || 0;
|
||||
|
||||
// If start is < now and end > now, we're in maintenance
|
||||
if (start <= now && end >= now) return true;
|
||||
|
||||
// If maintenance window was set in the past with a repeat,
|
||||
// we need to advance start and end to see if we are in range
|
||||
|
||||
while (start < now && repeatInterval !== 0) {
|
||||
start.setTime(start.getTime() + repeatInterval);
|
||||
end.setTime(end.getTime() + repeatInterval);
|
||||
if (start <= now && end >= now) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return acc;
|
||||
}, false);
|
||||
return maintenanceWindowIsActive;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuperSimpleQueueHelper;
|
||||
@@ -1,56 +1,42 @@
|
||||
<!-- name, email, url -->
|
||||
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font
|
||||
name="Roboto"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
|
||||
></mj-font>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
|
||||
<mj-text
|
||||
font-weight="300"
|
||||
font-size="16px"
|
||||
color="#616161"
|
||||
line-height="24px"
|
||||
></mj-text>
|
||||
<mj-section padding="0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section padding="20px 0">
|
||||
<mj-column width="100%">
|
||||
<mj-text
|
||||
align="left"
|
||||
font-size="10px"
|
||||
>
|
||||
Message from Checkmate Service
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column width="100%">
|
||||
<mj-text>
|
||||
<p>Hello {{ name }}!</p>
|
||||
<p>
|
||||
You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to
|
||||
reset your password.
|
||||
</p>
|
||||
<a href="{{url}}">Reset Password</a>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
<mj-head>
|
||||
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
|
||||
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
|
||||
<mj-section padding="0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section padding="20px 0">
|
||||
<mj-column width="100%">
|
||||
<mj-text align="left" font-size="10px">
|
||||
Message from Checkmate Service
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column width="100%">
|
||||
<mj-text>
|
||||
<p>Hello {{ name }}!</p>
|
||||
<p>
|
||||
You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to
|
||||
reset your password.
|
||||
</p>
|
||||
<a href="{{url}}">Reset Password</a>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="100%">
|
||||
<mj-divider
|
||||
border-width="1px"
|
||||
border-color="#E0E0E0"
|
||||
></mj-divider>
|
||||
<mj-text font-size="12px">
|
||||
<p>This email was sent by Checkmate.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
<p>Thank you.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="100%">
|
||||
<mj-divider border-width="1px" border-color="#E0E0E0"></mj-divider>
|
||||
<mj-text font-size="12px">
|
||||
<p>This email was sent by Checkmate.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,68 +1,42 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font
|
||||
name="Roboto"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
|
||||
></mj-font>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
|
||||
<mj-text
|
||||
font-weight="300"
|
||||
font-size="16px"
|
||||
color="#616161"
|
||||
line-height="24px"
|
||||
></mj-text>
|
||||
<mj-section padding="0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section padding="20px 0">
|
||||
<mj-column width="100%">
|
||||
<mj-text
|
||||
align="left"
|
||||
font-size="10px"
|
||||
>
|
||||
Message from Checkmate Service
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column
|
||||
width="45%"
|
||||
padding-top="20px"
|
||||
>
|
||||
<mj-text
|
||||
align="center"
|
||||
font-weight="500"
|
||||
padding="0px"
|
||||
font-size="18px"
|
||||
color="red"
|
||||
>
|
||||
{{ monitor }} is down
|
||||
</mj-text>
|
||||
<mj-divider
|
||||
border-width="2px"
|
||||
border-color="#616161"
|
||||
></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column width="100%">
|
||||
<mj-text>
|
||||
<p>Hello {{ name }}!</p>
|
||||
<p>We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.</p>
|
||||
<p><b>Monitor name:</b> {{ monitor }}</p>
|
||||
<p><b>URL:</b> {{ url }}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="100%">
|
||||
<mj-divider
|
||||
border-width="1px"
|
||||
border-color="#E0E0E0"
|
||||
></mj-divider>
|
||||
<mj-button background-color="#1570EF"> View incident details </mj-button>
|
||||
<mj-text font-size="12px">
|
||||
<p>This email was sent by Checkmate.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
|
||||
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
|
||||
<mj-section padding="0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section padding="20px 0">
|
||||
<mj-column width="100%">
|
||||
<mj-text align="left" font-size="10px">
|
||||
Message from Checkmate Service
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="45%" padding-top="20px">
|
||||
<mj-text align="center" font-weight="500" padding="0px" font-size="18px" color="red">
|
||||
{{ monitor }} is down
|
||||
</mj-text>
|
||||
<mj-divider border-width="2px" border-color="#616161"></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column width="100%">
|
||||
<mj-text>
|
||||
<p>Hello {{ name }}!</p>
|
||||
<p>We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.</p>
|
||||
<p><b>Monitor name:</b> {{ monitor }}</p>
|
||||
<p><b>URL:</b> {{ url }}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="100%">
|
||||
<mj-divider border-width="1px" border-color="#E0E0E0"></mj-divider>
|
||||
<mj-button background-color="#1570EF"> View incident details </mj-button>
|
||||
<mj-text font-size="12px">
|
||||
<p>This email was sent by Checkmate.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,51 +1,37 @@
|
||||
<!-- name -->
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font
|
||||
name="Roboto"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
|
||||
></mj-font>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
|
||||
<mj-text
|
||||
font-weight="300"
|
||||
font-size="16px"
|
||||
color="#616161"
|
||||
line-height="24px"
|
||||
></mj-text>
|
||||
<mj-section padding="0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section padding="20px 0">
|
||||
<mj-column width="100%">
|
||||
<mj-text
|
||||
align="left"
|
||||
font-size="10px"
|
||||
>
|
||||
Message from Checkmate Service
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column width="100%">
|
||||
<mj-text>
|
||||
<p>Hello {{ name }}!</p>
|
||||
<p>Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.</p>
|
||||
<p>Checkmate is an automated way of checking whether a service such as a website or an application is available or not.</p>
|
||||
<p>We hope you find our service as valuable as we do.</p>
|
||||
<p>Thank you.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="100%">
|
||||
<mj-divider
|
||||
border-width="1px"
|
||||
border-color="#E0E0E0"
|
||||
></mj-divider>
|
||||
<mj-text font-size="12px">
|
||||
<p>This email was sent by Checkmate.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
|
||||
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
|
||||
<mj-section padding="0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section padding="20px 0">
|
||||
<mj-column width="100%">
|
||||
<mj-text align="left" font-size="10px">
|
||||
Message from Checkmate Service
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column width="100%">
|
||||
<mj-text>
|
||||
<p>Hello {{ name }}!</p>
|
||||
<p>Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.</p>
|
||||
<p>Checkmate is an automated way of checking whether a service such as a website or an application is available or not.</p>
|
||||
<p>We hope you find our service as valuable as we do.</p>
|
||||
<p>Thank you.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="100%">
|
||||
<mj-divider border-width="1px" border-color="#E0E0E0"></mj-divider>
|
||||
<mj-text font-size="12px">
|
||||
<p>This email was sent by Checkmate.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
Reference in new issue
Block a user