Skip to content

Commit 9e33635

Browse files
Copilotrnwood
andauthored
feat: options to turn off HTML validation./compat results and move processing to background (#1835)
* Initial plan * feat: add configurable HTML validation with disable option and pagination Co-authored-by: rnwood <[email protected]> * feat: add configurable HTML compatibility check disable option Co-authored-by: rnwood <[email protected]> * feat: implement client-side web workers for HTML analysis processing Co-authored-by: rnwood <[email protected]> * fix: remove pagination from HTML validation table as requested Co-authored-by: rnwood <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: rnwood <[email protected]>
1 parent 3352633 commit 9e33635

14 files changed

Lines changed: 397 additions & 80 deletions

Rnwood.Smtp4dev/ApiModel/Server.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public class Server
6767

6868
public string CurrentUserDefaultMailboxName { get; set; }
6969
public string HtmlValidateConfig { get; set; }
70+
public bool DisableHtmlValidation { get; set; }
71+
public bool DisableHtmlCompatibilityCheck { get; set; }
7072
public string CommandValidationExpression { get; set; }
7173
}
7274

Rnwood.Smtp4dev/ClientApp/src/components/messageclientanalysis.vue

Lines changed: 33 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
<div>Message has no HTML body</div>
1313
</div>
1414

15-
<el-table class="fill table" stripe :data="warnings" v-if="message?.hasHtmlBody" empty-text="There are no warnings">
15+
<div v-if="message?.hasHtmlBody && isHtmlCompatibilityCheckDisabled" class="fill nodetails centrecontents">
16+
<div>HTML compatibility check is disabled</div>
17+
</div>
18+
19+
<el-table class="fill table" stripe :data="warnings" v-if="message?.hasHtmlBody && !isHtmlCompatibilityCheckDisabled" empty-text="There are no warnings">
1620
<el-table-column prop="feature" label="Feature" width="180">
1721
<template #default="scope">
1822
<span style="font-family: Courier New, Courier, monospace">{{scope.row.feature}}</span>
@@ -41,25 +45,40 @@
4145
4246
import MessagesController from "../ApiClient/MessagesController";
4347
import Message from "../ApiClient/Message";
44-
import { doIUseEmail } from '@jsx-email/doiuse-email';
48+
import HubConnectionManager from "../ApiClient/HubConnectionManager";
49+
import { HtmlCompatibilityWorkerManager, type CompatibilityWarning } from "../workers/HtmlCompatibilityWorkerManager";
4550
4651
@Component
4752
class MessageClientAnalysis extends Vue {
4853
4954
@Prop({ default: null })
5055
message: Message | null | undefined;
5156
57+
@Prop({ default: null })
58+
connection: HubConnectionManager | null = null;
59+
5260
error: Error | null = null;
5361
loading = false;
62+
isHtmlCompatibilityCheckDisabled = false;
63+
private workerManager = new HtmlCompatibilityWorkerManager();
5464
55-
warnings: { message: string, feature: string, type: string, browsers: string[], url: string, isError: boolean }[] =[];
65+
warnings: CompatibilityWarning[] = [];
5666
5767
@Watch("message")
5868
async onMessageChanged(value: Message | null, oldValue: Message | null) {
5969
6070
await this.loadMessage();
6171
}
6272
73+
@Watch("connection")
74+
onConnectionChanged() {
75+
if (this.connection) {
76+
this.connection.onServerChanged( async () => {
77+
await this.loadMessage();
78+
});
79+
}
80+
}
81+
6382
@Watch("warnings")
6483
onWarningsChanged() {
6584
this.fireWarningCountChanged()
@@ -70,74 +89,24 @@
7089
return this.warnings?.length ?? 0;
7190
}
7291
73-
private parseWarning(warning: string, isError: boolean) {
74-
75-
const details = { message: warning, type: "", feature: "", browser: "", url: "", isError: false };
76-
const detailsMatch = warning.match(/^`(.+)` (support )?is (.+) (by|for) `(.+)`$/);
77-
78-
if (detailsMatch) {
79-
details.feature = detailsMatch[1] ?? null;
80-
details.type = detailsMatch[3] ?? null;
81-
details.browser = detailsMatch[5] ?? null;
82-
details.isError = isError;
83-
84-
if (details.feature.endsWith(" element")) {
85-
details.url = `https://www.caniemail.com/features/html-${details.feature.replace("<", "").replace("> element", "")}/`;
86-
} else {
87-
details.url = `https://www.caniemail.com/features/css-${details.feature.replace(":", "-")}/`;
88-
89-
}
90-
} else {
91-
details.type = warning;
92-
}
93-
94-
return details;
95-
}
96-
9792
async loadMessage() {
9893
9994
this.warnings = [];
10095
this.error = null;
10196
this.loading = true;
10297
10398
try {
104-
const newWarnings = [];
105-
if (this.message != null && this.message.hasHtmlBody) {
106-
107-
const html = await new MessagesController().getMessageHtml(this.message.id);
108-
const doIUseResults = doIUseEmail(html, { emailClients: ["*"] });
109-
110-
const allWarnings = [];
111-
for (const warning of doIUseResults.warnings) {
112-
const details = this.parseWarning(warning, false);
113-
allWarnings.push(details);
99+
if (this.message != null && this.message.hasHtmlBody && this.connection) {
100+
const server = await this.connection.getServer();
101+
this.isHtmlCompatibilityCheckDisabled = server.disableHtmlCompatibilityCheck;
102+
103+
if (!this.isHtmlCompatibilityCheckDisabled) {
104+
const html = await new MessagesController().getMessageHtml(this.message.id);
105+
106+
// Use web worker for compatibility checking
107+
const compatibilityResults = await this.workerManager.checkCompatibility(html);
108+
this.warnings = compatibilityResults;
114109
}
115-
116-
if (doIUseResults.success == false) {
117-
for (const warning of doIUseResults.errors) {
118-
const details = this.parseWarning(warning,true);
119-
allWarnings.push(details);
120-
}
121-
122-
}
123-
124-
const allGrouped = Object.groupBy(allWarnings, i => i.feature + " " + i.type);
125-
for (const groupKey in allGrouped) {
126-
const groupItems = allGrouped[groupKey]!;
127-
newWarnings.push({
128-
type: groupItems[0].type,
129-
130-
feature: groupItems[0].feature,
131-
message: groupItems[0].message,
132-
133-
url: groupItems[0].url,
134-
browsers: groupItems.map(i => i.browser).filter((value, index, array) => array.indexOf(value) === index),
135-
isError: groupItems[0].isError
136-
})
137-
}
138-
139-
this.warnings = newWarnings;
140-
141110
}
142111
} catch (e: any) {
143112
this.error = e;
@@ -152,7 +121,7 @@
152121
}
153122
154123
async destroyed() {
155-
124+
this.workerManager.destroy();
156125
}
157126
158127
}

Rnwood.Smtp4dev/ClientApp/src/components/messagehtmlvalidation.vue

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
<div>Message has no HTML body</div>
1313
</div>
1414

15-
<el-table class="fill table" stripe :data="warnings" v-if="message?.hasHtmlBody" empty-text="There are no warnings">
15+
<div v-if="message?.hasHtmlBody && isHtmlValidationDisabled" class="fill nodetails centrecontents">
16+
<div>HTML validation is disabled</div>
17+
</div>
18+
19+
<el-table class="fill table" stripe :data="warnings" v-if="message?.hasHtmlBody && !isHtmlValidationDisabled" empty-text="There are no warnings">
1620
<el-table-column prop="message" label="Message" width="200">
1721
<template #default="scope">
1822
<a target="_blank" :href="scope.row.ruleUrl">{{scope.row.message}}</a>
@@ -43,7 +47,9 @@
4347
4448
import MessagesController from "../ApiClient/MessagesController";
4549
import Message from "../ApiClient/Message";
46-
import { HtmlValidate, Message as HtmlValidateMessage } from "html-validate";
50+
import { Message as HtmlValidateMessage } from "html-validate";
51+
import HubConnectionManager from "../ApiClient/HubConnectionManager";
52+
import { HtmlValidationWorkerManager } from "../workers/HtmlValidationWorkerManager";
4753
4854
@Component
4955
class MessageHtmlValidation extends Vue {
@@ -54,8 +60,10 @@
5460
error: Error | null = null;
5561
loading = false;
5662
html = "";
57-
63+
5864
warnings: HtmlValidateMessage[] = [];
65+
isHtmlValidationDisabled = false;
66+
private workerManager = new HtmlValidationWorkerManager();
5967
6068
6169
@Prop({ default: null })
@@ -89,6 +97,12 @@
8997
return this.warnings?.length ?? 0;
9098
}
9199
100+
async refresh() {
101+
if (this.connection) {
102+
await this.loadMessage();
103+
}
104+
}
105+
92106
93107
94108
async loadMessage() {
@@ -100,17 +114,18 @@
100114
101115
try {
102116
const newWarnings = [];
103-
if (this.message != null && this.message.hasHtmlBody) {
104-
105-
this.html = await new MessagesController().getMessageHtml(this.message.id);
106-
const config = JSON.parse((await this.connection.getServer()).htmlValidateConfig);
107-
108-
const report = await new HtmlValidate(config).validateString(this.html, "messagebody");
109-
for (const r of report.results) {
110-
newWarnings.push(...r.messages);
117+
if (this.message != null && this.message.hasHtmlBody && this.connection) {
118+
const server = await this.connection.getServer();
119+
this.isHtmlValidationDisabled = server.disableHtmlValidation;
120+
121+
if (!this.isHtmlValidationDisabled) {
122+
this.html = await new MessagesController().getMessageHtml(this.message.id);
123+
const config = JSON.parse(server.htmlValidateConfig);
124+
125+
// Use web worker for validation
126+
const validationResults = await this.workerManager.validateHtml(this.html, config);
127+
newWarnings.push(...validationResults);
111128
}
112-
113-
114129
}
115130
this.warnings = newWarnings;
116131
} catch (e: any) {
@@ -126,7 +141,7 @@
126141
}
127142
128143
async destroyed() {
129-
144+
this.workerManager.destroy();
130145
}
131146
132147
}

Rnwood.Smtp4dev/ClientApp/src/components/messageview.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133
<el-tag v-if="analysisWarningCount.clients" style="margin-left: 6px;" type="warning" size="small" effect="dark" round><el-icon><WarnTriangleFilled /></el-icon> {{analysisWarningCount.clients ? analysisWarningCount.clients : ''}}</el-tag>
134134

135135
</template>
136-
<messageclientanalysis class="fill" :message="message" @warning-count-changed="n => this.analysisWarningCount.clients=n"></messageclientanalysis>
136+
<messageclientanalysis class="fill" :connection="connection" :message="message" @warning-count-changed="n => this.analysisWarningCount.clients=n"></messageclientanalysis>
137137
</el-tab-pane>
138138

139139
<el-tab-pane label="HTML Validation" id="html" class="hfillpanel">

Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@
6262

6363
<el-switch v-model="server.disableMessageSanitisation" :disabled="server.lockedSettings.disableMessageSanitisation" />
6464
</el-form-item>
65+
66+
<el-form-item label="Disable HTML validation in Analysis tab" prop="server.disableHtmlValidation">
67+
<el-icon v-if="server.lockedSettings.disableHtmlValidation" :title="`Locked: ${server.lockedSettings.disableHtmlValidation}`"><Lock /></el-icon>
68+
69+
<el-switch v-model="server.disableHtmlValidation" :disabled="server.lockedSettings.disableHtmlValidation" />
70+
</el-form-item>
71+
72+
<el-form-item label="Disable HTML compatibility checks in Analysis tab" prop="server.disableHtmlCompatibilityCheck">
73+
<el-icon v-if="server.lockedSettings.disableHtmlCompatibilityCheck" :title="`Locked: ${server.lockedSettings.disableHtmlCompatibilityCheck}`"><Lock /></el-icon>
74+
75+
<el-switch v-model="server.disableHtmlCompatibilityCheck" :disabled="server.lockedSettings.disableHtmlCompatibilityCheck" />
76+
</el-form-item>
6577
</el-tab-pane>
6678
<el-tab-pane label="SMTP Server">
6779

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { CompatibilityRequest, CompatibilityResponse, CompatibilityWarning } from './htmlCompatibilityWorker';
2+
3+
export type { CompatibilityWarning };
4+
5+
export class HtmlCompatibilityWorkerManager {
6+
private worker: Worker | null = null;
7+
private requestId = 0;
8+
private pendingRequests = new Map<string, {
9+
resolve: (warnings: CompatibilityWarning[]) => void;
10+
reject: (error: Error) => void;
11+
}>();
12+
13+
private ensureWorker(): Worker {
14+
if (!this.worker) {
15+
this.worker = new Worker(
16+
new URL('./htmlCompatibilityWorker.ts', import.meta.url),
17+
{ type: 'module' }
18+
);
19+
20+
this.worker.onmessage = (event: MessageEvent<CompatibilityResponse>) => {
21+
const { requestId, warnings, error } = event.data;
22+
const pending = this.pendingRequests.get(requestId);
23+
24+
if (pending) {
25+
this.pendingRequests.delete(requestId);
26+
27+
if (error) {
28+
pending.reject(new Error(error));
29+
} else {
30+
pending.resolve(warnings);
31+
}
32+
}
33+
};
34+
35+
this.worker.onerror = (error) => {
36+
// Reject all pending requests
37+
for (const pending of this.pendingRequests.values()) {
38+
pending.reject(new Error(`Worker error: ${error.message}`));
39+
}
40+
this.pendingRequests.clear();
41+
};
42+
}
43+
44+
return this.worker;
45+
}
46+
47+
async checkCompatibility(html: string): Promise<CompatibilityWarning[]> {
48+
const worker = this.ensureWorker();
49+
const requestId = `req_${++this.requestId}`;
50+
51+
return new Promise<CompatibilityWarning[]>((resolve, reject) => {
52+
this.pendingRequests.set(requestId, { resolve, reject });
53+
54+
const request: CompatibilityRequest = {
55+
html,
56+
requestId
57+
};
58+
59+
worker.postMessage(request);
60+
});
61+
}
62+
63+
destroy(): void {
64+
if (this.worker) {
65+
this.worker.terminate();
66+
this.worker = null;
67+
}
68+
69+
// Reject all pending requests
70+
for (const pending of this.pendingRequests.values()) {
71+
pending.reject(new Error('Worker terminated'));
72+
}
73+
this.pendingRequests.clear();
74+
}
75+
}

0 commit comments

Comments
 (0)