Files
mailer/index.html
2025-09-11 16:59:42 +08:00

615 lines
23 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<link rel="icon" href="https://s3.dualstack.us-east-2.amazonaws.com/pythondotorg-assets/media/files/python-logo-only.svg" type="image/svg+xml">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css" integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/grids-responsive-min.css">
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/utc.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/timezone.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/duration.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/relativeTime.js"></script>
<title>Python Application</title>
<form class="pure-form pure-form-stacked pure-u-11-12 pure-u-lg-3-4 pure-u-xl-2-3">
<fieldset>
<legend>Basic Information</legend>
<div class="gaps pure-g">
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">File Name</span>
<span class="less ellipsis pure-u-3-4" id="fileLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Subject</span>
<span class="less ellipsis pure-u-3-4" id="subjectLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">From</span>
<span class="less ellipsis pure-u-3-4" id="fromLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Recipient</span>
<span class="less ellipsis pure-u-3-4" id="recipientLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Progress</span>
<span class="less ellipsis pure-u-3-4" id="progressLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Timezone</span>
<span class="less ellipsis pure-u-3-4" id="timezoneLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Uptime</span>
<span class="less ellipsis pure-u-3-4" id="uptimeLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Remaining</span>
<span class="less ellipsis pure-u-3-4" id="remainingLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Status</span>
<span class="less ellipsis pure-u-3-4" id="statusLabel"></span>
</div>
</div>
<br>
<button type="button" class="inline-flex pure-button pure-button-primary" id="send">
<span class="text">Open</span>
<span class="icon spin" hidden>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-loader">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 6l0 -3" />
<path d="M16.25 7.75l2.15 -2.15" />
<path d="M18 12l3 0" />
<path d="M16.25 16.25l2.15 2.15" />
<path d="M12 18l0 3" />
<path d="M7.75 16.25l-2.15 2.15" />
<path d="M6 12l-3 0" />
<path d="M7.75 7.75l-2.15 -2.15" />
</svg>
</span>
</button>
<button type="button" class="pure-button" id="skip" disabled>
Skip
</button>
<button type="button" class="pure-button" id="cancel" disabled>
Cancel
</button>
</fieldset>
<br>
<fieldset>
<legend>Parameters</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="timeout">Timeout</label>
<input id="timeout" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="interval">Interval</label>
<input id="interval" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="max_occurrence">Max Occurrence</label>
<input id="max_occurrence" class="inactive pure-u-23-24" type="number" min="0" value="5" disabled/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="attempts">Attempts</label>
<input id="attempts" class="pure-u-23-24" type="number" min="1"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="locale">Locale</label>
<select id="locale" class="pure-input-1-2" required>
</select>
</div>
</div>
<br>
<label for="greet" class="pure-checkbox">
<input id="greet" type="checkbox"/> Greet recipients (by proper names if possible)
</label>
<label for="recovery" class="pure-checkbox">
<input id="recovery" type="checkbox"/> Try recover from a mismatched address/subject error
</label>
<label for="slice" class="pure-checkbox">
<input id="slice" type="checkbox"/> Enable task slicing
</label>
<label for="nospam" class="pure-checkbox">
<input id="nospam" type="checkbox"/> Avoid spamming
</label>
<label for="save" class="pure-checkbox">
<input id="save" type="checkbox" checked/> Save to file
</label>
</fieldset>
<br>
<fieldset>
<legend>Columns</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_address">Receipient Address</label>
<select id="column_address" class="columns pure-u-23-24" required>
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_name">Receipient Name</label>
<select id="column_name" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_code">Reference Code</label>
<select id="column_code" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_pays">Country</label>
<select id="column_pays" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_sent">Remarks</label>
<select id="column_sent" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
</div>
</fieldset>
<br>
<fieldset>
<legend>Slicing</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="group_by">Group by</label>
<select id="group_by" class="slicing localonly pure-u-23-24" disabled>
<option value="none">None</option>
<option value="locale">Locale</option>
<option value="country">Country</option>
</select>
<label for="subcategory">Subcategory</label>
<select id="subcategory" class="slicing inactive localonly pure-u-23-24" multiple disabled>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="chunk_size">Chunk Size</label>
<input id="chunk_size" class="slicing pure-u-23-24" type="number" min="100" value="1000" disabled/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<div class="inline-flex">
<div class="pure-u-1-2">
<label for="offset">Offset</label>
<input id="offset" class="slicing pure-u-23-24" type="number" min="0" disabled/>
</div>
<div class="pure-u-1-2">
<label for="limit">Max. (limit)</label>
<input id="limit" class="slicing localonly pure-u-23-24" type="number" readonly disabled/>
</div>
</div>
</div>
</div>
</fieldset>
</form>
<script type="text/javascript">
dayjs.extend(dayjs_plugin_utc);
dayjs.extend(dayjs_plugin_timezone);
dayjs.extend(dayjs_plugin_duration);
dayjs.extend(dayjs_plugin_relativeTime);
const Timer = {
setTimestamp: (reset=false) => {
$('#uptimeLabel').dataset.timestamp = dayjs().unix();
if (reset) $('#uptimeLabel').dataset.timedelta = 0;
},
setTimedelta: () => {
$('#uptimeLabel').dataset.timedelta = Timer.getTimedelta().asSeconds();
},
getTimedelta: () => {
let timedelta = dayjs.duration(dayjs().diff(dayjs.unix($('#uptimeLabel').dataset.timestamp)));
let sum = dayjs.duration($('#uptimeLabel').dataset.timedelta||0, 'second').add(timedelta);
return sum;
},
};
const $ = (selectors) => {
return document.querySelector(selectors);
};
const $$ = (selectors) => {
return document.querySelectorAll(selectors);
};
const $$$ = (selectors, event, listener) => {
$(selectors).addEventListener(event, listener);
};
let Limit = null;
let Status = null;
let Columns = null;
let Locales = null;
let Connection = null;
$$$('#send', 'click', async (e) => {
switch (Status) {
case 'STANDBY':
Connection.send('RESUME');
$('#send > span.text').innerText = 'Pause';
$('#send > span.icon').removeAttribute('hidden');
$('#send').disabled = true;
break;
case 'RUNNING':
Connection.send('ONHOLD');
$('#send > span.text').innerText = 'Resume';
$('#send > span.icon').setAttribute('hidden', '');
$('#send').disabled = true;
break;
case 'READY':
let parameters = {};
for (let element of $$("input[type='number']:not([disabled]):not(.localonly)")) {
parameters[element.id] = element.valueAsNumber;
}
for (let element of $$("select:not([disabled]):not(.localonly)")) {
parameters[element.id] = element.value || element.dataset.default;
}
for (let element of $$("input[type='checkbox']:not([disabled])")) {
parameters[element.id] = element.checked;
}
if (parameters.slice && $('#subcategory').dataset.values) {
parameters.subcategory = $('#subcategory').dataset.values.split(',');
}
for (let element of $$("input:not(.inactive), select:not(.inactive)")) {
element.disabled = true;
}
Connection.send(JSON.stringify(parameters));
Status = 'RUNNING';
Timer.setTimestamp(true);
$('#send > span.text').innerText = 'Pause';
$('#send > span.icon').removeAttribute('hidden');
$('#skip').disabled = false;
break;
default:
try {
let PickerOptions = {
types: [
{
description: "Excel Spreadsheet",
accept: {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ['.xlsx']
}
}
]
};
$('#send > span.icon').removeAttribute('hidden');
let [handle] = await showOpenFilePicker(PickerOptions);
let file = await handle.getFile();
let buffer = await new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
Connection.send(file.name);
Connection.send(buffer);
Status = 'READY';
$('#fileLabel').innerText = file.name;
$('#send > span.text').innerText = 'Send';
$('#cancel').disabled = false;
$('#send').disabled = true;
} finally {
$('#send > span.icon').setAttribute('hidden', '');
}
}
});
$$$('#skip', 'click', (e) => {
Connection.send('BYPASS');
if (Status === 'STANDBY') Connection.send('ONHOLD');
$('#skip').disabled = true;
});
$$$('#cancel', 'click', (e) => {
Connection.send('CANCEL');
$('#cancel').disabled = true;
});
$$$('#locale', 'change', (e) => {
$('#group_by').dispatchEvent(new Event('change'));
$('#timezoneLabel').dataset.timezone = Locales.find((x) => x.locale == e.target.value).timezone;
});
$$$('#slice', 'change', (e) => {
for (let element of $$('.slicing:not(.inactive)')) {
element.disabled = !e.target.checked;
}
$('#subcategory').dispatchEvent(new Event('change'));
});
$$$('#nospam', 'change', (e) => {
$('#max_occurrence').classList.toggle('inactive');
$('#max_occurrence').disabled = !e.target.checked;
$('#column_code').required = e.target.checked;
});
$$$('#column_pays', 'change', (e) => {
$('#group_by').dispatchEvent(new Event('change'));
});
$$$('#group_by', 'change', (e) => {
let columns = Columns ? Columns[$('#column_pays').value] : null;
let countries = Object.assign({}, columns);
let results = {};
switch (e.target.value) {
case 'none':
case 'country':
results = countries;
break;
case 'locale':
let locale = $('#locale').value;
let targets = Locales.toReversed();
for (let target of targets) {
for (let key of Object.keys(countries)) {
if (target.predicate.length == 0 || target.predicate.includes(key)) {
if (target.locale === locale) {
results[key] = countries[key];
}
delete countries[key];
}
}
}
break;
}
switch (e.target.value) {
case 'none':
$('#subcategory').classList.add('inactive');
$('#subcategory').disabled = true;
break;
case 'country':
case 'locale':
$('#subcategory').classList.remove('inactive');
$('#subcategory').disabled = false;
break;
}
$('#subcategory').options.length = 0;
for (let [key, value] of Object.entries(results)) {
let option = new Option(`${key} (${value})`, key);
option.dataset.count = value;
$('#subcategory').options.add(option);
}
$('#subcategory').dispatchEvent(new Event('change'));
});
$$$('#subcategory', 'change', (e) => {
let options = Array.from(e.target.options).filter((v) => v.selected);
let values = options.map((o) => o.value).join(',');
let limit = options.reduce((i, v) => i + Number(v.dataset.count), 0);
$('#subcategory').dataset.limit = limit;
$('#subcategory').dataset.values = values;
$('#chunk_size').dispatchEvent(new Event('change'));
});
$$$('#chunk_size', 'change', (e) => {
let limit = Number($('#subcategory').dataset.limit) || Limit;
let size = Math.floor(limit / e.target.value);
$('#limit').value = size;
$('#offset').max = size;
$('#offset').value = 0;
$('#offset').dispatchEvent(new Event('change'));
});
$$$('#offset', 'change', (e) => {
let limit = Number($('#subcategory').dataset.limit) || Limit;
let size = Number($('#chunk_size').value) || 0;
if ($('#slice').checked) {
limit = Math.min(size, limit - size * Number(e.target.value));
}
e.target.dataset.limit = limit;
});
function main(url, parameters, locales) {
Connection = new WebSocket(url);
Locales = JSON.parse(locales);
for (let [key, value] of Object.entries(parameters)) {
$(`input#${key}`)?.setAttribute('value', value);
}
for (let item of Locales) {
$('#locale').add(new Option(item.locale.toUpperCase(), item.locale));
$('#locale').dispatchEvent(new Event('change'))
}
Connection.addEventListener('message', (e) => {
let command = JSON.parse(e.data);
let {name, args} = command;
switch (name) {
case 'setAddress':
let [person, address] = args;
document.title = `${person} (${address})`;
$('#fromLabel').innerText = address;
break;
case 'setSubject':
let [subject] = args;
$('#subjectLabel').innerText = subject;
break;
case 'setMetadata':
[Limit, Columns] = args;
for (let element of $$('.columns')) {
let options = element.options;
options.length = 1;
for (let column of Object.keys(Columns)) {
options.add(new Option(column, column, false, column === parameters[element.id]));
}
element.dataset.default = parameters[element.id];
element.dispatchEvent(new Event('change'));
}
$('#send').disabled = false;
$('#cancel').disabled = false;
break;
case 'setProgress':
let [index, name, recipient, sent, warnings, errors, attempt] = args;
let label = name ? `${name}<${recipient}>` : recipient;
$('#recipientLabel').innerText = label;
$('#progressLabel').dataset.sent = sent;
$('#progressLabel').dataset.index = index;
$('#progressLabel').dataset.errors = errors;
$('#progressLabel').dataset.attempt = attempt;
$('#progressLabel').dataset.warnings = warnings;
break;
case 'setStatus':
switch (args[0]) {
case 'RESUME':
Timer.setTimestamp();
case 'BYPASS':
Status = 'RUNNING';
$('#send').disabled = false;
$('#skip').disabled = false;
break;
case 'ONHOLD':
Status = 'STANDBY';
$('#send').disabled = false;
Timer.setTimedelta();
break;
case 'FAILED':
Status = 'STANDBY';
$('#send > span.text').innerText = 'Resume';
$('#send > span.icon').setAttribute('hidden', '');
Timer.setTimedelta();
break;
case 'FINISH':
let {sent, warnings, errors} = $('#progressLabel').dataset;
alert(`${sent||0} Sent; ${warnings||0} Warning(s); ${errors||0} Error(s)`);
case 'CANCEL':
Status = null;
$('#send > span.text').innerText = 'Open';
$('#send > span.icon').setAttribute('hidden', '');
$('#cancel').disabled = true;
$('#skip').disabled = true;
for (let element of $$("input:not(.inactive):not(.slicing)")) {
element.disabled = false;
}
for (let element of $$("select:not(.inactive):not(.slicing)")) {
element.disabled = false;
}
for (let element of $$("input[type='checkbox']")) {
element.dispatchEvent(new Event('change'));
}
break;
}
break;
case 'tell':
let [message, level] = args;
let prefix = level === 0 ? 'ERROR' : level === 1 ? 'WARNING' : 'INFO';
console.log(`[${prefix}] ${message}`);
if (level === 0) alert(message);
break;
}
});
let clock = setInterval(() => {
let timezone = $('#timezoneLabel').dataset.timezone;
let city = timezone.split('/')[1];
let date = dayjs().tz(timezone).format("YYYY-MM-DD HH:mm:ss");
$('#timezoneLabel').innerText = `${date} (${city})`;
$('#statusLabel').innerText = Status ? Status.charAt(0).toUpperCase() + Status.slice(1).toLowerCase() : 'Idle';
if (Status === 'RUNNING') {
let index = Number($('#progressLabel').dataset.index) || 0;
let limit = Number($('#offset').dataset.limit) || 0;
let percentage = parseFloat((index / limit * 100).toFixed(2));
$('#progressLabel').innerText = `${index} / ${limit} (${percentage} %)`;
let uptime = Timer.getTimedelta();
$('#uptimeLabel').innerText = uptime.format("HH:mm:ss");
let rate = Number($('#progressLabel').dataset.sent) / uptime.asSeconds();
let spm = parseFloat(Number(rate*60).toFixed(2));
$('#remainingLabel').innerHTML = rate > 0 ? `<span>${dayjs.duration((limit - index) / rate, 'second').humanize()}</span>` : '';
$('#remainingLabel').innerHTML += spm >= 1 ? `<span class="${spm > 8.33 ? 'rate warning' : 'rate'}">${spm} per minute</span>` : '';
}
}, 500);
}
</script>
<style type="text/css">
body {
width: 100%;
margin: 2em 0 2em 0;
display: inline-flex;
justify-content: center;
}
form {
padding: 0 1em 0 1em;
}
label {
user-select: none;
width: fit-content;
}
.rate:not(:first-child)::before {
padding: 0em 0.4em;
content: '';
color: #666;
}
.inline-flex {
display: inline-flex;
align-items: center;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.warning {
color: orangered;
}
.gaps {
row-gap: 0.4em;
}
.icon {
height: 1em;
}
.less {
color: #666;
max-width: 72%;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>