Update MyTischtennis functionality to support automatic rating updates. Introduce new autoUpdateRatings field in MyTischtennis model and enhance MyTischtennisController to handle update history retrieval. Integrate node-cron for scheduling daily updates at 6:00 AM. Update frontend components to allow users to enable/disable automatic updates and display last update timestamps.
This commit is contained in:
@@ -42,7 +42,7 @@ class MyTischtennisController {
|
||||
async upsertAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { email, password, savePassword, userPassword } = req.body;
|
||||
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError(400, 'E-Mail-Adresse erforderlich');
|
||||
@@ -58,6 +58,7 @@ class MyTischtennisController {
|
||||
email,
|
||||
password,
|
||||
savePassword || false,
|
||||
autoUpdateRatings || false,
|
||||
userPassword
|
||||
);
|
||||
|
||||
@@ -127,6 +128,20 @@ class MyTischtennisController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/update-history
|
||||
* Get update ratings history
|
||||
*/
|
||||
async getUpdateHistory(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const history = await myTischtennisService.getUpdateHistory(userId);
|
||||
res.status(200).json({ history });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisController();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration: Add auto update ratings fields to my_tischtennis table
|
||||
-- Date: 2025-01-27
|
||||
|
||||
-- Add auto_update_ratings column
|
||||
ALTER TABLE my_tischtennis
|
||||
ADD COLUMN auto_update_ratings BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Add last_update_ratings column
|
||||
ALTER TABLE my_tischtennis
|
||||
ADD COLUMN last_update_ratings TIMESTAMP NULL;
|
||||
|
||||
-- Create index for auto_update_ratings for efficient querying
|
||||
CREATE INDEX idx_my_tischtennis_auto_update_ratings ON my_tischtennis(auto_update_ratings);
|
||||
23
backend/migrations/create_my_tischtennis_update_history.sql
Normal file
23
backend/migrations/create_my_tischtennis_update_history.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Migration: Create my_tischtennis_update_history table
|
||||
-- Date: 2025-01-27
|
||||
-- For MariaDB
|
||||
|
||||
CREATE TABLE IF NOT EXISTS my_tischtennis_update_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
message TEXT,
|
||||
error_details TEXT,
|
||||
updated_count INT DEFAULT 0,
|
||||
execution_time INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT fk_my_tischtennis_update_history_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX idx_my_tischtennis_update_history_user_id ON my_tischtennis_update_history(user_id);
|
||||
CREATE INDEX idx_my_tischtennis_update_history_created_at ON my_tischtennis_update_history(created_at);
|
||||
CREATE INDEX idx_my_tischtennis_update_history_success ON my_tischtennis_update_history(success);
|
||||
@@ -34,6 +34,12 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
allowNull: false,
|
||||
field: 'save_password'
|
||||
},
|
||||
autoUpdateRatings: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
field: 'auto_update_ratings'
|
||||
},
|
||||
accessToken: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
@@ -82,6 +88,11 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_login_success'
|
||||
},
|
||||
lastUpdateRatings: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_update_ratings'
|
||||
}
|
||||
}, {
|
||||
underscored: true,
|
||||
|
||||
63
backend/models/MyTischtennisUpdateHistory.js
Normal file
63
backend/models/MyTischtennisUpdateHistory.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const MyTischtennisUpdateHistory = sequelize.define('MyTischtennisUpdateHistory', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
success: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
errorDetails: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_details'
|
||||
},
|
||||
updatedCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
field: 'updated_count'
|
||||
},
|
||||
executionTime: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Execution time in milliseconds',
|
||||
field: 'execution_time'
|
||||
}
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'my_tischtennis_update_history',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
},
|
||||
{
|
||||
fields: ['success']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export default MyTischtennisUpdateHistory;
|
||||
@@ -36,6 +36,7 @@ import OfficialTournament from './OfficialTournament.js';
|
||||
import OfficialCompetition from './OfficialCompetition.js';
|
||||
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
|
||||
import MyTischtennis from './MyTischtennis.js';
|
||||
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -227,6 +228,9 @@ DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' });
|
||||
User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' });
|
||||
MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' });
|
||||
MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
export {
|
||||
User,
|
||||
Log,
|
||||
@@ -265,4 +269,5 @@ export {
|
||||
OfficialCompetition,
|
||||
OfficialCompetitionMember,
|
||||
MyTischtennis,
|
||||
MyTischtennisUpdateHistory,
|
||||
};
|
||||
|
||||
16
backend/node_modules/.package-lock.json
generated
vendored
16
backend/node_modules/.package-lock.json
generated
vendored
@@ -2816,6 +2816,15 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
@@ -2842,9 +2851,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.9.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
|
||||
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
||||
7
backend/node_modules/nodemailer/.ncurc.js
generated
vendored
7
backend/node_modules/nodemailer/.ncurc.js
generated
vendored
@@ -1,10 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
reject: [
|
||||
// API changes break existing tests
|
||||
'proxy',
|
||||
|
||||
// API changes
|
||||
'eslint'
|
||||
'proxy'
|
||||
]
|
||||
};
|
||||
|
||||
2
backend/node_modules/nodemailer/.prettierrc.js
generated
vendored
2
backend/node_modules/nodemailer/.prettierrc.js
generated
vendored
@@ -1,3 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
printWidth: 160,
|
||||
tabWidth: 4,
|
||||
|
||||
655
backend/node_modules/nodemailer/CHANGELOG.md
generated
vendored
655
backend/node_modules/nodemailer/CHANGELOG.md
generated
vendored
File diff suppressed because it is too large
Load Diff
26
backend/node_modules/nodemailer/CODE_OF_CONDUCT.md
generated
vendored
26
backend/node_modules/nodemailer/CODE_OF_CONDUCT.md
generated
vendored
@@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation.
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
|
||||
24
backend/node_modules/nodemailer/README.md
generated
vendored
24
backend/node_modules/nodemailer/README.md
generated
vendored
@@ -37,36 +37,36 @@ It's either a firewall issue, or your SMTP server blocks authentication attempts
|
||||
|
||||
#### I get TLS errors
|
||||
|
||||
- If you are running the code on your machine, check your antivirus settings. Antiviruses often mess around with email ports usage. Node.js might not recognize the MITM cert your antivirus is using.
|
||||
- Latest Node versions allow only TLS versions 1.2 and higher. Some servers might still use TLS 1.1 or lower. Check Node.js docs on how to get correct TLS support for your app. You can change this with [tls.minVersion](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tls_tls_createsecurecontext_options) option
|
||||
- You might have the wrong value for the `secure` option. This should be set to `true` only for port 465. For every other port, it should be `false`. Setting it to `false` does not mean that Nodemailer would not use TLS. Nodemailer would still try to upgrade the connection to use TLS if the server supports it.
|
||||
- Older Node versions do not fully support the certificate chain of the newest Let's Encrypt certificates. Either set [tls.rejectUnauthorized](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tlsconnectoptions-callback) to `false` to skip chain verification or upgrade your Node version
|
||||
- If you are running the code on your machine, check your antivirus settings. Antiviruses often mess around with email ports usage. Node.js might not recognize the MITM cert your antivirus is using.
|
||||
- Latest Node versions allow only TLS versions 1.2 and higher. Some servers might still use TLS 1.1 or lower. Check Node.js docs on how to get correct TLS support for your app. You can change this with [tls.minVersion](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tls_tls_createsecurecontext_options) option
|
||||
- You might have the wrong value for the `secure` option. This should be set to `true` only for port 465. For every other port, it should be `false`. Setting it to `false` does not mean that Nodemailer would not use TLS. Nodemailer would still try to upgrade the connection to use TLS if the server supports it.
|
||||
- Older Node versions do not fully support the certificate chain of the newest Let's Encrypt certificates. Either set [tls.rejectUnauthorized](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tlsconnectoptions-callback) to `false` to skip chain verification or upgrade your Node version
|
||||
|
||||
```
|
||||
```js
|
||||
let configOptions = {
|
||||
host: "smtp.example.com",
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
tls: {
|
||||
rejectUnauthorized: true,
|
||||
minVersion: "TLSv1.2"
|
||||
minVersion: 'TLSv1.2'
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### I have issues with DNS / hosts file
|
||||
|
||||
Node.js uses [c-ares](https://nodejs.org/en/docs/meta/topics/dependencies/#c-ares) to resolve domain names, not the DNS library provided by the system, so if you have some custom DNS routing set up, it might be ignored. Nodemailer runs [dns.resolve4()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve4hostname-options-callback) and [dns.resolve6()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve6hostname-options-callback) to resolve hostname into an IP address. If both calls fail, then Nodemailer will fall back to [dns.lookup()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnslookuphostname-options-callback). If this does not work for you, you can hard code the IP address into the configuration like shown below. In that case, Nodemailer would not perform any DNS lookups.
|
||||
|
||||
```
|
||||
```js
|
||||
let configOptions = {
|
||||
host: "1.2.3.4",
|
||||
host: '1.2.3.4',
|
||||
port: 465,
|
||||
secure: true,
|
||||
tls: {
|
||||
// must provide server name, otherwise TLS certificate check will fail
|
||||
servername: "example.com"
|
||||
servername: 'example.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### I have an issue with TypeScript types
|
||||
|
||||
84
backend/node_modules/nodemailer/lib/addressparser/index.js
generated
vendored
84
backend/node_modules/nodemailer/lib/addressparser/index.js
generated
vendored
@@ -7,7 +7,6 @@
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
let token;
|
||||
let isGroup = false;
|
||||
let state = 'text';
|
||||
let address;
|
||||
@@ -16,28 +15,41 @@ function _handleAddress(tokens) {
|
||||
address: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
text: [],
|
||||
textWasQuoted: [] // Track which text tokens came from inside quotes
|
||||
};
|
||||
let i;
|
||||
let len;
|
||||
let insideQuotes = false; // Track if we're currently inside a quoted string
|
||||
|
||||
// Filter out <addresses>, (comments) and regular text
|
||||
for (i = 0, len = tokens.length; i < len; i++) {
|
||||
token = tokens[i];
|
||||
let token = tokens[i];
|
||||
let prevToken = i ? tokens[i - 1] : null;
|
||||
if (token.type === 'operator') {
|
||||
switch (token.value) {
|
||||
case '<':
|
||||
state = 'address';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case '(':
|
||||
state = 'comment';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case ':':
|
||||
state = 'group';
|
||||
isGroup = true;
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case '"':
|
||||
// Track quote state for text tokens
|
||||
insideQuotes = !insideQuotes;
|
||||
state = 'text';
|
||||
break;
|
||||
default:
|
||||
state = 'text';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
}
|
||||
} else if (token.value) {
|
||||
if (state === 'address') {
|
||||
@@ -46,7 +58,19 @@ function _handleAddress(tokens) {
|
||||
// and so will we
|
||||
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
||||
}
|
||||
data[state].push(token.value);
|
||||
|
||||
if (prevToken && prevToken.noBreak && data[state].length) {
|
||||
// join values
|
||||
data[state][data[state].length - 1] += token.value;
|
||||
if (state === 'text' && insideQuotes) {
|
||||
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
||||
}
|
||||
} else {
|
||||
data[state].push(token.value);
|
||||
if (state === 'text') {
|
||||
data.textWasQuoted.push(insideQuotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,16 +83,36 @@ function _handleAddress(tokens) {
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
data.text = data.text.join(' ');
|
||||
|
||||
// Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
|
||||
let groupMembers = [];
|
||||
if (data.group.length) {
|
||||
let parsedGroup = addressparser(data.group.join(','));
|
||||
// Flatten: if any member is itself a group, extract its members into the sequence
|
||||
parsedGroup.forEach(member => {
|
||||
if (member.group) {
|
||||
// Nested group detected - flatten it by adding its members directly
|
||||
groupMembers = groupMembers.concat(member.group);
|
||||
} else {
|
||||
groupMembers.push(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addresses.push({
|
||||
name: data.text || (address && address.name),
|
||||
group: data.group.length ? addressparser(data.group.join(',')) : []
|
||||
group: groupMembers
|
||||
});
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.address.length && data.text.length) {
|
||||
for (i = data.text.length - 1; i >= 0; i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
// Security fix: Do not extract email addresses from quoted strings
|
||||
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
|
||||
// Extracting emails from quoted text leads to misrouting vulnerabilities
|
||||
if (!data.textWasQuoted[i] && data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.address = data.text.splice(i, 1);
|
||||
data.textWasQuoted.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -85,10 +129,13 @@ function _handleAddress(tokens) {
|
||||
// still no address
|
||||
if (!data.address.length) {
|
||||
for (i = data.text.length - 1; i >= 0; i--) {
|
||||
// fixed the regex to parse email address correctly when email address has more than one @
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
|
||||
if (data.address.length) {
|
||||
break;
|
||||
// Security fix: Do not extract email addresses from quoted strings
|
||||
if (!data.textWasQuoted[i]) {
|
||||
// fixed the regex to parse email address correctly when email address has more than one @
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
|
||||
if (data.address.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,11 +219,12 @@ class Tokenizer {
|
||||
* @return {Array} An array of operator|text tokens
|
||||
*/
|
||||
tokenize() {
|
||||
let chr,
|
||||
list = [];
|
||||
let list = [];
|
||||
|
||||
for (let i = 0, len = this.str.length; i < len; i++) {
|
||||
chr = this.str.charAt(i);
|
||||
this.checkChar(chr);
|
||||
let chr = this.str.charAt(i);
|
||||
let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
|
||||
this.checkChar(chr, nextChr);
|
||||
}
|
||||
|
||||
this.list.forEach(node => {
|
||||
@@ -194,7 +242,7 @@ class Tokenizer {
|
||||
*
|
||||
* @param {String} chr Character from the address field
|
||||
*/
|
||||
checkChar(chr) {
|
||||
checkChar(chr, nextChr) {
|
||||
if (this.escaped) {
|
||||
// ignore next condition blocks
|
||||
} else if (chr === this.operatorExpecting) {
|
||||
@@ -202,10 +250,16 @@ class Tokenizer {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
};
|
||||
|
||||
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
|
||||
this.node.noBreak = true;
|
||||
}
|
||||
|
||||
this.list.push(this.node);
|
||||
this.node = null;
|
||||
this.operatorExpecting = '';
|
||||
this.escaped = false;
|
||||
|
||||
return;
|
||||
} else if (!this.operatorExpecting && chr in this.operators) {
|
||||
this.node = {
|
||||
|
||||
25
backend/node_modules/nodemailer/lib/base64/index.js
generated
vendored
25
backend/node_modules/nodemailer/lib/base64/index.js
generated
vendored
@@ -35,15 +35,12 @@ function wrap(str, lineLength) {
|
||||
let pos = 0;
|
||||
let chunkLength = lineLength * 1024;
|
||||
while (pos < str.length) {
|
||||
let wrappedLines = str
|
||||
.substr(pos, chunkLength)
|
||||
.replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n')
|
||||
.trim();
|
||||
let wrappedLines = str.substr(pos, chunkLength).replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n');
|
||||
result.push(wrappedLines);
|
||||
pos += chunkLength;
|
||||
}
|
||||
|
||||
return result.join('\r\n').trim();
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +53,6 @@ function wrap(str, lineLength) {
|
||||
class Encoder extends Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
// init Transform
|
||||
this.options = options || {};
|
||||
|
||||
if (this.options.lineLength !== false) {
|
||||
@@ -98,17 +94,20 @@ class Encoder extends Transform {
|
||||
if (this.options.lineLength) {
|
||||
b64 = wrap(b64, this.options.lineLength);
|
||||
|
||||
// remove last line as it is still most probably incomplete
|
||||
let lastLF = b64.lastIndexOf('\n');
|
||||
if (lastLF < 0) {
|
||||
this._curLine = b64;
|
||||
b64 = '';
|
||||
} else if (lastLF === b64.length - 1) {
|
||||
this._curLine = '';
|
||||
} else {
|
||||
this._curLine = b64.substr(lastLF + 1);
|
||||
b64 = b64.substr(0, lastLF + 1);
|
||||
this._curLine = b64.substring(lastLF + 1);
|
||||
b64 = b64.substring(0, lastLF + 1);
|
||||
|
||||
if (b64 && !b64.endsWith('\r\n')) {
|
||||
b64 += '\r\n';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._curLine = '';
|
||||
}
|
||||
|
||||
if (b64) {
|
||||
@@ -125,16 +124,14 @@ class Encoder extends Transform {
|
||||
}
|
||||
|
||||
if (this._curLine) {
|
||||
this._curLine = wrap(this._curLine, this.options.lineLength);
|
||||
this.outputBytes += this._curLine.length;
|
||||
this.push(this._curLine, 'ascii');
|
||||
this.push(Buffer.from(this._curLine, 'ascii'));
|
||||
this._curLine = '';
|
||||
}
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
// expose to the world
|
||||
module.exports = {
|
||||
encode,
|
||||
wrap,
|
||||
|
||||
6
backend/node_modules/nodemailer/lib/dkim/index.js
generated
vendored
6
backend/node_modules/nodemailer/lib/dkim/index.js
generated
vendored
@@ -12,7 +12,7 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DKIM_ALGO = 'sha256';
|
||||
const MAX_MESSAGE_SIZE = 128 * 1024; // buffer messages larger than this to disk
|
||||
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
|
||||
|
||||
/*
|
||||
// Usage:
|
||||
@@ -42,7 +42,9 @@ class DKIMSigner {
|
||||
this.chunks = [];
|
||||
this.chunklen = 0;
|
||||
this.readPos = 0;
|
||||
this.cachePath = this.cacheDir ? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex')) : false;
|
||||
this.cachePath = this.cacheDir
|
||||
? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex'))
|
||||
: false;
|
||||
this.cache = false;
|
||||
|
||||
this.headers = false;
|
||||
|
||||
2
backend/node_modules/nodemailer/lib/dkim/sign.js
generated
vendored
2
backend/node_modules/nodemailer/lib/dkim/sign.js
generated
vendored
@@ -41,7 +41,7 @@ module.exports = (headers, hashAlgo, bodyHash, options) => {
|
||||
signer.update(canonicalizedHeaderData.headers);
|
||||
try {
|
||||
signature = signer.sign(options.privateKey, 'base64');
|
||||
} catch (E) {
|
||||
} catch (_E) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
8
backend/node_modules/nodemailer/lib/fetch/index.js
generated
vendored
8
backend/node_modules/nodemailer/lib/fetch/index.js
generated
vendored
@@ -132,7 +132,13 @@ function nmfetch(url, options) {
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'https:' && parsed.hostname && parsed.hostname !== reqOptions.host && !net.isIP(parsed.hostname) && !reqOptions.servername) {
|
||||
if (
|
||||
parsed.protocol === 'https:' &&
|
||||
parsed.hostname &&
|
||||
parsed.hostname !== reqOptions.host &&
|
||||
!net.isIP(parsed.hostname) &&
|
||||
!reqOptions.servername
|
||||
) {
|
||||
reqOptions.servername = parsed.hostname;
|
||||
}
|
||||
|
||||
|
||||
72
backend/node_modules/nodemailer/lib/mail-composer/index.js
generated
vendored
72
backend/node_modules/nodemailer/lib/mail-composer/index.js
generated
vendored
@@ -86,20 +86,34 @@ class MailComposer {
|
||||
let icalEvent, eventObject;
|
||||
let attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
|
||||
let data;
|
||||
let isMessageNode = /^message\//i.test(attachment.contentType);
|
||||
|
||||
if (/^data:/i.test(attachment.path || attachment.href)) {
|
||||
attachment = this._processDataUrl(attachment);
|
||||
}
|
||||
|
||||
let contentType = attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
|
||||
let contentType =
|
||||
attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
|
||||
|
||||
let isImage = /^image\//i.test(contentType);
|
||||
let contentDisposition = attachment.contentDisposition || (isMessageNode || (isImage && attachment.cid) ? 'inline' : 'attachment');
|
||||
let isMessageNode = /^message\//i.test(contentType);
|
||||
|
||||
let contentDisposition =
|
||||
attachment.contentDisposition || (isMessageNode || (isImage && attachment.cid) ? 'inline' : 'attachment');
|
||||
|
||||
let contentTransferEncoding;
|
||||
if ('contentTransferEncoding' in attachment) {
|
||||
// also contains `false`, to set
|
||||
contentTransferEncoding = attachment.contentTransferEncoding;
|
||||
} else if (isMessageNode) {
|
||||
contentTransferEncoding = '7bit';
|
||||
} else {
|
||||
contentTransferEncoding = 'base64'; // the default
|
||||
}
|
||||
|
||||
data = {
|
||||
contentType,
|
||||
contentDisposition,
|
||||
contentTransferEncoding: 'contentTransferEncoding' in attachment ? attachment.contentTransferEncoding : 'base64'
|
||||
contentTransferEncoding
|
||||
};
|
||||
|
||||
if (attachment.filename) {
|
||||
@@ -200,7 +214,10 @@ class MailComposer {
|
||||
eventObject;
|
||||
|
||||
if (this.mail.text) {
|
||||
if (typeof this.mail.text === 'object' && (this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)) {
|
||||
if (
|
||||
typeof this.mail.text === 'object' &&
|
||||
(this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)
|
||||
) {
|
||||
text = this.mail.text;
|
||||
} else {
|
||||
text = {
|
||||
@@ -225,7 +242,10 @@ class MailComposer {
|
||||
}
|
||||
|
||||
if (this.mail.amp) {
|
||||
if (typeof this.mail.amp === 'object' && (this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)) {
|
||||
if (
|
||||
typeof this.mail.amp === 'object' &&
|
||||
(this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)
|
||||
) {
|
||||
amp = this.mail.amp;
|
||||
} else {
|
||||
amp = {
|
||||
@@ -260,14 +280,18 @@ class MailComposer {
|
||||
}
|
||||
|
||||
eventObject.filename = false;
|
||||
eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
|
||||
eventObject.contentType =
|
||||
'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
|
||||
if (!eventObject.headers) {
|
||||
eventObject.headers = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mail.html) {
|
||||
if (typeof this.mail.html === 'object' && (this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)) {
|
||||
if (
|
||||
typeof this.mail.html === 'object' &&
|
||||
(this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)
|
||||
) {
|
||||
html = this.mail.html;
|
||||
} else {
|
||||
html = {
|
||||
@@ -292,7 +316,9 @@ class MailComposer {
|
||||
}
|
||||
|
||||
data = {
|
||||
contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
|
||||
contentType:
|
||||
alternative.contentType ||
|
||||
mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
|
||||
contentTransferEncoding: alternative.contentTransferEncoding
|
||||
};
|
||||
|
||||
@@ -538,9 +564,33 @@ class MailComposer {
|
||||
* @return {Object} Parsed element
|
||||
*/
|
||||
_processDataUrl(element) {
|
||||
const dataUrl = element.path || element.href;
|
||||
|
||||
// Early validation to prevent ReDoS
|
||||
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||
return element;
|
||||
}
|
||||
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
return element;
|
||||
}
|
||||
|
||||
if (dataUrl.length > 100000) {
|
||||
// 100KB limit for data URL string
|
||||
// Return empty content for excessively long data URLs
|
||||
return Object.assign({}, element, {
|
||||
path: false,
|
||||
href: false,
|
||||
content: Buffer.alloc(0),
|
||||
contentType: element.contentType || 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
let parsedDataUri;
|
||||
if ((element.path || element.href).match(/^data:/)) {
|
||||
parsedDataUri = parseDataURI(element.path || element.href);
|
||||
try {
|
||||
parsedDataUri = parseDataURI(dataUrl);
|
||||
} catch (_err) {
|
||||
return element;
|
||||
}
|
||||
|
||||
if (!parsedDataUri) {
|
||||
|
||||
14
backend/node_modules/nodemailer/lib/mailer/index.js
generated
vendored
14
backend/node_modules/nodemailer/lib/mailer/index.js
generated
vendored
@@ -87,6 +87,11 @@ class Mail extends EventEmitter {
|
||||
this.transporter.on('idle', (...args) => {
|
||||
this.emit('idle', ...args);
|
||||
});
|
||||
|
||||
// indicates if the sender has became idle and all connections are terminated
|
||||
this.transporter.on('clear', (...args) => {
|
||||
this.emit('clear', ...args);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +241,14 @@ class Mail extends EventEmitter {
|
||||
}
|
||||
|
||||
getVersionString() {
|
||||
return util.format('%s (%s; +%s; %s/%s)', packageData.name, packageData.version, packageData.homepage, this.transporter.name, this.transporter.version);
|
||||
return util.format(
|
||||
'%s (%s; +%s; %s/%s)',
|
||||
packageData.name,
|
||||
packageData.version,
|
||||
packageData.homepage,
|
||||
this.transporter.name,
|
||||
this.transporter.version
|
||||
);
|
||||
}
|
||||
|
||||
_processPlugins(step, mail, callback) {
|
||||
|
||||
3
backend/node_modules/nodemailer/lib/mailer/mail-message.js
generated
vendored
3
backend/node_modules/nodemailer/lib/mailer/mail-message.js
generated
vendored
@@ -64,7 +64,8 @@ class MailMessage {
|
||||
if (this.data.attachments && this.data.attachments.length) {
|
||||
this.data.attachments.forEach((attachment, i) => {
|
||||
if (!attachment.filename) {
|
||||
attachment.filename = (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
|
||||
attachment.filename =
|
||||
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
|
||||
if (attachment.filename.indexOf('.') < 0) {
|
||||
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
|
||||
}
|
||||
|
||||
4
backend/node_modules/nodemailer/lib/mime-funcs/index.js
generated
vendored
4
backend/node_modules/nodemailer/lib/mime-funcs/index.js
generated
vendored
@@ -269,7 +269,7 @@ module.exports = {
|
||||
|
||||
// first line includes the charset and language info and needs to be encoded
|
||||
// even if it does not contain any unicode characters
|
||||
line = 'utf-8\x27\x27';
|
||||
line = "utf-8''";
|
||||
let encoded = true;
|
||||
startPos = 0;
|
||||
|
||||
@@ -614,7 +614,7 @@ module.exports = {
|
||||
try {
|
||||
// might throw if we try to encode invalid sequences, eg. partial emoji
|
||||
str = encodeURIComponent(str);
|
||||
} catch (E) {
|
||||
} catch (_E) {
|
||||
// should never run
|
||||
return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
|
||||
}
|
||||
|
||||
17
backend/node_modules/nodemailer/lib/mime-funcs/mime-types.js
generated
vendored
17
backend/node_modules/nodemailer/lib/mime-funcs/mime-types.js
generated
vendored
@@ -44,6 +44,7 @@ const mimeTypes = new Map([
|
||||
['application/fractals', 'fif'],
|
||||
['application/freeloader', 'frl'],
|
||||
['application/futuresplash', 'spl'],
|
||||
['application/geo+json', 'geojson'],
|
||||
['application/gnutar', 'tgz'],
|
||||
['application/groupwise', 'vew'],
|
||||
['application/hlp', 'hlp'],
|
||||
@@ -1101,7 +1102,10 @@ const extensions = new Map([
|
||||
['bdm', 'application/vnd.syncml.dm+wbxml'],
|
||||
['bed', 'application/vnd.realvnc.bed'],
|
||||
['bh2', 'application/vnd.fujitsu.oasysprs'],
|
||||
['bin', ['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']],
|
||||
[
|
||||
'bin',
|
||||
['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']
|
||||
],
|
||||
['bm', 'image/bmp'],
|
||||
['bmi', 'application/vnd.bmi'],
|
||||
['bmp', ['image/bmp', 'image/x-windows-bmp']],
|
||||
@@ -1146,7 +1150,10 @@ const extensions = new Map([
|
||||
['cii', 'application/vnd.anser-web-certificate-issue-initiation'],
|
||||
['cil', 'application/vnd.ms-artgalry'],
|
||||
['cla', 'application/vnd.claymore'],
|
||||
['class', ['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']],
|
||||
[
|
||||
'class',
|
||||
['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']
|
||||
],
|
||||
['clkk', 'application/vnd.crick.clicker.keyboard'],
|
||||
['clkp', 'application/vnd.crick.clicker.palette'],
|
||||
['clkt', 'application/vnd.crick.clicker.template'],
|
||||
@@ -1287,6 +1294,7 @@ const extensions = new Map([
|
||||
['gac', 'application/vnd.groove-account'],
|
||||
['gdl', 'model/vnd.gdl'],
|
||||
['geo', 'application/vnd.dynageo'],
|
||||
['geojson', 'application/geo+json'],
|
||||
['gex', 'application/vnd.geometry-explorer'],
|
||||
['ggb', 'application/vnd.geogebra.file'],
|
||||
['ggt', 'application/vnd.geogebra.tool'],
|
||||
@@ -1750,7 +1758,10 @@ const extensions = new Map([
|
||||
['sbml', 'application/sbml+xml'],
|
||||
['sc', 'application/vnd.ibm.secure-container'],
|
||||
['scd', 'application/x-msschedule'],
|
||||
['scm', ['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']],
|
||||
[
|
||||
'scm',
|
||||
['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']
|
||||
],
|
||||
['scq', 'application/scvp-cv-request'],
|
||||
['scs', 'application/scvp-cv-response'],
|
||||
['sct', 'text/scriptlet'],
|
||||
|
||||
20
backend/node_modules/nodemailer/lib/mime-node/index.js
generated
vendored
20
backend/node_modules/nodemailer/lib/mime-node/index.js
generated
vendored
@@ -552,7 +552,11 @@ class MimeNode {
|
||||
|
||||
this._handleContentType(structured);
|
||||
|
||||
if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
|
||||
if (
|
||||
structured.value.match(/^text\/plain\b/) &&
|
||||
typeof this.content === 'string' &&
|
||||
/[\u0080-\uFFFF]/.test(this.content)
|
||||
) {
|
||||
structured.params.charset = 'utf-8';
|
||||
}
|
||||
|
||||
@@ -963,8 +967,8 @@ class MimeNode {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
contentStream.end(content._resolvedValue);
|
||||
} catch (err) {
|
||||
contentStream.emit('error', err);
|
||||
} catch (_err) {
|
||||
contentStream.emit('error', _err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -995,8 +999,8 @@ class MimeNode {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
contentStream.end(content || '');
|
||||
} catch (err) {
|
||||
contentStream.emit('error', err);
|
||||
} catch (_err) {
|
||||
contentStream.emit('error', _err);
|
||||
}
|
||||
});
|
||||
return contentStream;
|
||||
@@ -1014,7 +1018,6 @@ class MimeNode {
|
||||
return [].concat.apply(
|
||||
[],
|
||||
[].concat(addresses).map(address => {
|
||||
// eslint-disable-line prefer-spread
|
||||
if (address && address.address) {
|
||||
address.address = this._normalizeAddress(address.address);
|
||||
address.name = address.name || '';
|
||||
@@ -1113,7 +1116,6 @@ class MimeNode {
|
||||
.apply(
|
||||
[],
|
||||
[].concat(value || '').map(elm => {
|
||||
// eslint-disable-line prefer-spread
|
||||
elm = (elm || '')
|
||||
.toString()
|
||||
.replace(/\r?\n|\r/g, ' ')
|
||||
@@ -1219,7 +1221,7 @@ class MimeNode {
|
||||
|
||||
try {
|
||||
encodedDomain = punycode.toASCII(domain.toLowerCase());
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// keep as is?
|
||||
}
|
||||
|
||||
@@ -1282,7 +1284,7 @@ class MimeNode {
|
||||
// count latin alphabet symbols and 8-bit range symbols + control symbols
|
||||
// if there are more latin characters, then use quoted-printable
|
||||
// encoding, otherwise use base64
|
||||
nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length; // eslint-disable-line no-control-regex
|
||||
nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length;
|
||||
latinLen = (value.match(/[a-z]/gi) || []).length;
|
||||
// if there are more latin symbols than binary/unicode, then prefer Q, otherwise B
|
||||
encoding = nonLatinLen < latinLen ? 'Q' : 'B';
|
||||
|
||||
7
backend/node_modules/nodemailer/lib/nodemailer.js
generated
vendored
7
backend/node_modules/nodemailer/lib/nodemailer.js
generated
vendored
@@ -45,6 +45,13 @@ module.exports.createTransport = function (transporter, defaults) {
|
||||
} else if (options.jsonTransport) {
|
||||
transporter = new JSONTransport(options);
|
||||
} else if (options.SES) {
|
||||
if (options.SES.ses && options.SES.aws) {
|
||||
let error = new Error(
|
||||
'Using legacy SES configuration, expecting @aws-sdk/client-sesv2, see https://nodemailer.com/transports/ses/'
|
||||
);
|
||||
error.code = 'LegacyConfig';
|
||||
throw error;
|
||||
}
|
||||
transporter = new SESTransport(options);
|
||||
} else {
|
||||
transporter = new SMTPTransport(options);
|
||||
|
||||
12
backend/node_modules/nodemailer/lib/qp/index.js
generated
vendored
12
backend/node_modules/nodemailer/lib/qp/index.js
generated
vendored
@@ -28,7 +28,10 @@ function encode(buffer) {
|
||||
for (let i = 0, len = buffer.length; i < len; i++) {
|
||||
ord = buffer[i];
|
||||
// if the char is in allowed range, then keep as is, unless it is a WS in the end of a line
|
||||
if (checkRanges(ord, ranges) && !((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) {
|
||||
if (
|
||||
checkRanges(ord, ranges) &&
|
||||
!((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))
|
||||
) {
|
||||
result += String.fromCharCode(ord);
|
||||
continue;
|
||||
}
|
||||
@@ -90,7 +93,12 @@ function wrap(str, lineLength) {
|
||||
}
|
||||
|
||||
// ensure that utf-8 sequences are not split
|
||||
while (line.length > 3 && line.length < len - pos && !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && (match = line.match(/[=][\da-f]{2}$/gi))) {
|
||||
while (
|
||||
line.length > 3 &&
|
||||
line.length < len - pos &&
|
||||
!line.match(/^(?:=[\da-f]{2}){1,4}$/i) &&
|
||||
(match = line.match(/[=][\da-f]{2}$/gi))
|
||||
) {
|
||||
code = parseInt(match[0].substr(1, 2), 16);
|
||||
if (code < 128) {
|
||||
break;
|
||||
|
||||
221
backend/node_modules/nodemailer/lib/ses-transport/index.js
generated
vendored
221
backend/node_modules/nodemailer/lib/ses-transport/index.js
generated
vendored
@@ -4,15 +4,11 @@ const EventEmitter = require('events');
|
||||
const packageData = require('../../package.json');
|
||||
const shared = require('../shared');
|
||||
const LeWindows = require('../mime-node/le-windows');
|
||||
const MimeNode = require('../mime-node');
|
||||
|
||||
/**
|
||||
* Generates a Transport object for AWS SES
|
||||
*
|
||||
* Possible options can be the following:
|
||||
*
|
||||
* * **sendingRate** optional Number specifying how many messages per second should be delivered to SES
|
||||
* * **maxConnections** optional Number specifying max number of parallel connections to SES
|
||||
*
|
||||
* @constructor
|
||||
* @param {Object} optional config parameter
|
||||
*/
|
||||
@@ -30,119 +26,17 @@ class SESTransport extends EventEmitter {
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'ses-transport'
|
||||
});
|
||||
|
||||
// parallel sending connections
|
||||
this.maxConnections = Number(this.options.maxConnections) || Infinity;
|
||||
this.connections = 0;
|
||||
|
||||
// max messages per second
|
||||
this.sendingRate = Number(this.options.sendingRate) || Infinity;
|
||||
this.sendingRateTTL = null;
|
||||
this.rateInterval = 1000; // milliseconds
|
||||
this.rateMessages = [];
|
||||
|
||||
this.pending = [];
|
||||
|
||||
this.idling = true;
|
||||
|
||||
setImmediate(() => {
|
||||
if (this.idling) {
|
||||
this.emit('idle');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a sending of a message
|
||||
*
|
||||
* @param {Object} emailMessage MailComposer object
|
||||
* @param {Function} callback Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail, callback) {
|
||||
if (this.connections >= this.maxConnections) {
|
||||
this.idling = false;
|
||||
return this.pending.push({
|
||||
mail,
|
||||
callback
|
||||
});
|
||||
getRegion(cb) {
|
||||
if (this.ses.sesClient.config && typeof this.ses.sesClient.config.region === 'function') {
|
||||
// promise
|
||||
return this.ses.sesClient.config
|
||||
.region()
|
||||
.then(region => cb(null, region))
|
||||
.catch(err => cb(err));
|
||||
}
|
||||
|
||||
if (!this._checkSendingRate()) {
|
||||
this.idling = false;
|
||||
return this.pending.push({
|
||||
mail,
|
||||
callback
|
||||
});
|
||||
}
|
||||
|
||||
this._send(mail, (...args) => {
|
||||
setImmediate(() => callback(...args));
|
||||
this._sent();
|
||||
});
|
||||
}
|
||||
|
||||
_checkRatedQueue() {
|
||||
if (this.connections >= this.maxConnections || !this._checkSendingRate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pending.length) {
|
||||
if (!this.idling) {
|
||||
this.idling = true;
|
||||
this.emit('idle');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let next = this.pending.shift();
|
||||
this._send(next.mail, (...args) => {
|
||||
setImmediate(() => next.callback(...args));
|
||||
this._sent();
|
||||
});
|
||||
}
|
||||
|
||||
_checkSendingRate() {
|
||||
clearTimeout(this.sendingRateTTL);
|
||||
|
||||
let now = Date.now();
|
||||
let oldest = false;
|
||||
// delete older messages
|
||||
for (let i = this.rateMessages.length - 1; i >= 0; i--) {
|
||||
if (this.rateMessages[i].ts >= now - this.rateInterval && (!oldest || this.rateMessages[i].ts < oldest)) {
|
||||
oldest = this.rateMessages[i].ts;
|
||||
}
|
||||
|
||||
if (this.rateMessages[i].ts < now - this.rateInterval && !this.rateMessages[i].pending) {
|
||||
this.rateMessages.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.rateMessages.length < this.sendingRate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let delay = Math.max(oldest + 1001, now + 20);
|
||||
this.sendingRateTTL = setTimeout(() => this._checkRatedQueue(), now - delay);
|
||||
|
||||
try {
|
||||
this.sendingRateTTL.unref();
|
||||
} catch (E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_sent() {
|
||||
this.connections--;
|
||||
this._checkRatedQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are free slots in the queue
|
||||
*/
|
||||
isIdle() {
|
||||
return this.idling;
|
||||
return cb(null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,13 +45,17 @@ class SESTransport extends EventEmitter {
|
||||
* @param {Object} emailMessage MailComposer object
|
||||
* @param {Function} callback Callback function to run when the sending is completed
|
||||
*/
|
||||
_send(mail, callback) {
|
||||
send(mail, callback) {
|
||||
let statObject = {
|
||||
ts: Date.now(),
|
||||
pending: true
|
||||
};
|
||||
this.connections++;
|
||||
this.rateMessages.push(statObject);
|
||||
|
||||
let fromHeader = mail.message._headers.find(header => /^from$/i.test(header.key));
|
||||
if (fromHeader) {
|
||||
let mimeNode = new MimeNode('text/plain');
|
||||
fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
|
||||
}
|
||||
|
||||
let envelope = mail.data.envelope || mail.message.getEnvelope();
|
||||
let messageId = mail.message.messageId();
|
||||
@@ -227,45 +125,29 @@ class SESTransport extends EventEmitter {
|
||||
}
|
||||
|
||||
let sesMessage = {
|
||||
RawMessage: {
|
||||
// required
|
||||
Data: raw // required
|
||||
Content: {
|
||||
Raw: {
|
||||
// required
|
||||
Data: raw // required
|
||||
}
|
||||
},
|
||||
Source: envelope.from,
|
||||
Destinations: envelope.to
|
||||
FromEmailAddress: fromHeader ? fromHeader : envelope.from,
|
||||
Destination: {
|
||||
ToAddresses: envelope.to
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(mail.data.ses || {}).forEach(key => {
|
||||
sesMessage[key] = mail.data.ses[key];
|
||||
});
|
||||
|
||||
let ses = (this.ses.aws ? this.ses.ses : this.ses) || {};
|
||||
let aws = this.ses.aws || {};
|
||||
|
||||
let getRegion = cb => {
|
||||
if (ses.config && typeof ses.config.region === 'function') {
|
||||
// promise
|
||||
return ses.config
|
||||
.region()
|
||||
.then(region => cb(null, region))
|
||||
.catch(err => cb(err));
|
||||
}
|
||||
return cb(null, (ses.config && ses.config.region) || 'us-east-1');
|
||||
};
|
||||
|
||||
getRegion((err, region) => {
|
||||
this.getRegion((err, region) => {
|
||||
if (err || !region) {
|
||||
region = 'us-east-1';
|
||||
}
|
||||
|
||||
let sendPromise;
|
||||
if (typeof ses.send === 'function' && aws.SendRawEmailCommand) {
|
||||
// v3 API
|
||||
sendPromise = ses.send(new aws.SendRawEmailCommand(sesMessage));
|
||||
} else {
|
||||
// v2 API
|
||||
sendPromise = ses.sendRawEmail(sesMessage).promise();
|
||||
}
|
||||
const command = new this.ses.SendEmailCommand(sesMessage);
|
||||
const sendPromise = this.ses.sesClient.send(command);
|
||||
|
||||
sendPromise
|
||||
.then(data => {
|
||||
@@ -273,7 +155,7 @@ class SESTransport extends EventEmitter {
|
||||
region = 'email';
|
||||
}
|
||||
|
||||
statObject.pending = false;
|
||||
statObject.pending = true;
|
||||
callback(null, {
|
||||
envelope: {
|
||||
from: envelope.from,
|
||||
@@ -309,38 +191,41 @@ class SESTransport extends EventEmitter {
|
||||
*/
|
||||
verify(callback) {
|
||||
let promise;
|
||||
let ses = (this.ses.aws ? this.ses.ses : this.ses) || {};
|
||||
let aws = this.ses.aws || {};
|
||||
|
||||
const sesMessage = {
|
||||
RawMessage: {
|
||||
// required
|
||||
Data: 'From: invalid@invalid\r\nTo: invalid@invalid\r\n Subject: Invalid\r\n\r\nInvalid'
|
||||
},
|
||||
Source: 'invalid@invalid',
|
||||
Destinations: ['invalid@invalid']
|
||||
};
|
||||
|
||||
if (!callback) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = shared.callbackPromise(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
const cb = err => {
|
||||
if (err && (err.code || err.Code) !== 'InvalidParameterValue') {
|
||||
if (err && !['InvalidParameterValue', 'MessageRejected'].includes(err.code || err.Code || err.name)) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, true);
|
||||
};
|
||||
|
||||
if (typeof ses.send === 'function' && aws.SendRawEmailCommand) {
|
||||
// v3 API
|
||||
sesMessage.RawMessage.Data = Buffer.from(sesMessage.RawMessage.Data);
|
||||
ses.send(new aws.SendRawEmailCommand(sesMessage), cb);
|
||||
} else {
|
||||
// v2 API
|
||||
ses.sendRawEmail(sesMessage, cb);
|
||||
}
|
||||
const sesMessage = {
|
||||
Content: {
|
||||
Raw: {
|
||||
Data: Buffer.from('From: <invalid@invalid>\r\nTo: <invalid@invalid>\r\n Subject: Invalid\r\n\r\nInvalid')
|
||||
}
|
||||
},
|
||||
FromEmailAddress: 'invalid@invalid',
|
||||
Destination: {
|
||||
ToAddresses: ['invalid@invalid']
|
||||
}
|
||||
};
|
||||
|
||||
this.getRegion((err, region) => {
|
||||
if (err || !region) {
|
||||
region = 'us-east-1';
|
||||
}
|
||||
|
||||
const command = new this.ses.SendEmailCommand(sesMessage);
|
||||
const sendPromise = this.ses.sesClient.send(command);
|
||||
|
||||
sendPromise.then(data => cb(null, data)).catch(err => cb(err));
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
154
backend/node_modules/nodemailer/lib/shared/index.js
generated
vendored
154
backend/node_modules/nodemailer/lib/shared/index.js
generated
vendored
@@ -11,11 +11,19 @@ const net = require('net');
|
||||
const os = require('os');
|
||||
|
||||
const DNS_TTL = 5 * 60 * 1000;
|
||||
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
|
||||
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
|
||||
|
||||
let lastCacheCleanup = 0;
|
||||
module.exports._lastCacheCleanup = () => lastCacheCleanup;
|
||||
module.exports._resetCacheCleanup = () => {
|
||||
lastCacheCleanup = 0;
|
||||
};
|
||||
|
||||
let networkInterfaces;
|
||||
try {
|
||||
networkInterfaces = os.networkInterfaces();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// fails on some systems
|
||||
}
|
||||
|
||||
@@ -81,8 +89,8 @@ const formatDNSValue = (value, extra) => {
|
||||
!value.addresses || !value.addresses.length
|
||||
? null
|
||||
: value.addresses.length === 1
|
||||
? value.addresses[0]
|
||||
: value.addresses[Math.floor(Math.random() * value.addresses.length)]
|
||||
? value.addresses[0]
|
||||
: value.addresses[Math.floor(Math.random() * value.addresses.length)]
|
||||
},
|
||||
extra || {}
|
||||
);
|
||||
@@ -113,7 +121,27 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
if (dnsCache.has(options.host)) {
|
||||
cached = dnsCache.get(options.host);
|
||||
|
||||
if (!cached.expires || cached.expires >= Date.now()) {
|
||||
// Lazy cleanup with time throttling
|
||||
const now = Date.now();
|
||||
if (now - lastCacheCleanup > CACHE_CLEANUP_INTERVAL) {
|
||||
lastCacheCleanup = now;
|
||||
|
||||
// Clean up expired entries
|
||||
for (const [host, entry] of dnsCache.entries()) {
|
||||
if (entry.expires && entry.expires < now) {
|
||||
dnsCache.delete(host);
|
||||
}
|
||||
}
|
||||
|
||||
// If cache is still too large, remove oldest entries
|
||||
if (dnsCache.size > MAX_CACHE_SIZE) {
|
||||
const toDelete = Math.floor(MAX_CACHE_SIZE * 0.1); // Remove 10% of entries
|
||||
const keys = Array.from(dnsCache.keys()).slice(0, toDelete);
|
||||
keys.forEach(key => dnsCache.delete(key));
|
||||
}
|
||||
}
|
||||
|
||||
if (!cached.expires || cached.expires >= now) {
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -126,7 +154,11 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
resolver(4, options.host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
// ignore error, use expired value
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -160,7 +192,11 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
resolver(6, options.host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
// ignore error, use expired value
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -195,7 +231,11 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
dns.lookup(options.host, { all: true }, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
// ignore error, use expired value
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -246,9 +286,13 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
if (cached) {
|
||||
// ignore error, use expired value
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -419,52 +463,74 @@ module.exports.callbackPromise = (resolve, reject) =>
|
||||
};
|
||||
|
||||
module.exports.parseDataURI = uri => {
|
||||
let input = uri;
|
||||
let commaPos = input.indexOf(',');
|
||||
if (!commaPos) {
|
||||
return uri;
|
||||
if (typeof uri !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = input.substring(commaPos + 1);
|
||||
let metaStr = input.substring('data:'.length, commaPos);
|
||||
// Early return for non-data URIs to avoid unnecessary processing
|
||||
if (!uri.startsWith('data:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the first comma safely - this prevents ReDoS
|
||||
const commaPos = uri.indexOf(',');
|
||||
if (commaPos === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = uri.substring(commaPos + 1);
|
||||
const metaStr = uri.substring('data:'.length, commaPos);
|
||||
|
||||
let encoding;
|
||||
const metaEntries = metaStr.split(';');
|
||||
|
||||
let metaEntries = metaStr.split(';');
|
||||
let lastMetaEntry = metaEntries.length > 1 ? metaEntries[metaEntries.length - 1] : false;
|
||||
if (lastMetaEntry && lastMetaEntry.indexOf('=') < 0) {
|
||||
encoding = lastMetaEntry.toLowerCase();
|
||||
metaEntries.pop();
|
||||
}
|
||||
|
||||
let contentType = metaEntries.shift() || 'application/octet-stream';
|
||||
let params = {};
|
||||
for (let entry of metaEntries) {
|
||||
let sep = entry.indexOf('=');
|
||||
if (sep >= 0) {
|
||||
let key = entry.substring(0, sep);
|
||||
let value = entry.substring(sep + 1);
|
||||
params[key] = value;
|
||||
if (metaEntries.length > 0) {
|
||||
const lastEntry = metaEntries[metaEntries.length - 1].toLowerCase().trim();
|
||||
// Only recognize valid encoding types to prevent manipulation
|
||||
if (['base64', 'utf8', 'utf-8'].includes(lastEntry) && lastEntry.indexOf('=') === -1) {
|
||||
encoding = lastEntry;
|
||||
metaEntries.pop();
|
||||
}
|
||||
}
|
||||
|
||||
switch (encoding) {
|
||||
case 'base64':
|
||||
data = Buffer.from(data, 'base64');
|
||||
break;
|
||||
case 'utf8':
|
||||
data = Buffer.from(data);
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
data = Buffer.from(decodeURIComponent(data));
|
||||
} catch (err) {
|
||||
data = Buffer.from(data);
|
||||
const contentType = metaEntries.length > 0 ? metaEntries.shift() : 'application/octet-stream';
|
||||
const params = {};
|
||||
|
||||
for (let i = 0; i < metaEntries.length; i++) {
|
||||
const entry = metaEntries[i];
|
||||
const sepPos = entry.indexOf('=');
|
||||
if (sepPos > 0) {
|
||||
// Ensure there's a key before the '='
|
||||
const key = entry.substring(0, sepPos).trim();
|
||||
const value = entry.substring(sepPos + 1).trim();
|
||||
if (key) {
|
||||
params[key] = value;
|
||||
}
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, encoding, contentType, params };
|
||||
// Decode data based on encoding with proper error handling
|
||||
let bufferData;
|
||||
try {
|
||||
if (encoding === 'base64') {
|
||||
bufferData = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
try {
|
||||
bufferData = Buffer.from(decodeURIComponent(data));
|
||||
} catch (_decodeError) {
|
||||
bufferData = Buffer.from(data);
|
||||
}
|
||||
}
|
||||
} catch (_bufferError) {
|
||||
bufferData = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
return {
|
||||
data: bufferData,
|
||||
encoding: encoding || null,
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
params
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
4
backend/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js
generated
vendored
4
backend/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js
generated
vendored
@@ -51,7 +51,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
|
||||
finished = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (E) {
|
||||
} catch (_E) {
|
||||
// ignore
|
||||
}
|
||||
callback(err);
|
||||
@@ -118,7 +118,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
|
||||
if (!match || (match[1] || '').charAt(0) !== '2') {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (E) {
|
||||
} catch (_E) {
|
||||
// ignore
|
||||
}
|
||||
return callback(new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')));
|
||||
|
||||
34
backend/node_modules/nodemailer/lib/smtp-connection/index.js
generated
vendored
34
backend/node_modules/nodemailer/lib/smtp-connection/index.js
generated
vendored
@@ -124,7 +124,7 @@ class SMTPConnection extends EventEmitter {
|
||||
|
||||
/**
|
||||
* The socket connecting to the server
|
||||
* @publick
|
||||
* @public
|
||||
*/
|
||||
this._socket = false;
|
||||
|
||||
@@ -243,6 +243,8 @@ class SMTPConnection extends EventEmitter {
|
||||
if (this.options.connection) {
|
||||
// connection is already opened
|
||||
this._socket = this.options.connection;
|
||||
setupConnectionHandlers();
|
||||
|
||||
if (this.secureConnection && !this.alreadySecured) {
|
||||
setImmediate(() =>
|
||||
this._upgradeConnection(err => {
|
||||
@@ -412,8 +414,8 @@ class SMTPConnection extends EventEmitter {
|
||||
|
||||
if (socket && !socket.destroyed) {
|
||||
try {
|
||||
this._socket[closeMethod]();
|
||||
} catch (E) {
|
||||
socket[closeMethod]();
|
||||
} catch (_E) {
|
||||
// just ignore
|
||||
}
|
||||
}
|
||||
@@ -628,6 +630,15 @@ class SMTPConnection extends EventEmitter {
|
||||
let startTime = Date.now();
|
||||
this._setEnvelope(envelope, (err, info) => {
|
||||
if (err) {
|
||||
// create passthrough stream to consume to prevent OOM
|
||||
let stream = new PassThrough();
|
||||
if (typeof message.pipe === 'function') {
|
||||
message.pipe(stream);
|
||||
} else {
|
||||
stream.write(message);
|
||||
stream.end();
|
||||
}
|
||||
|
||||
return callback(err);
|
||||
}
|
||||
let envelopeTime = Date.now();
|
||||
@@ -1283,7 +1294,12 @@ class SMTPConnection extends EventEmitter {
|
||||
|
||||
if (str.charAt(0) !== '2') {
|
||||
if (this.options.requireTLS) {
|
||||
this._onError(new Error('EHLO failed but HELO does not support required STARTTLS. response=' + str), 'ECONNECTION', str, 'EHLO');
|
||||
this._onError(
|
||||
new Error('EHLO failed but HELO does not support required STARTTLS. response=' + str),
|
||||
'ECONNECTION',
|
||||
str,
|
||||
'EHLO'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1465,7 +1481,9 @@ class SMTPConnection extends EventEmitter {
|
||||
let challengeString = '';
|
||||
|
||||
if (!challengeMatch) {
|
||||
return callback(this._formatError('Invalid login sequence while waiting for server challenge string', 'EAUTH', str, 'AUTH CRAM-MD5'));
|
||||
return callback(
|
||||
this._formatError('Invalid login sequence while waiting for server challenge string', 'EAUTH', str, 'AUTH CRAM-MD5')
|
||||
);
|
||||
} else {
|
||||
challengeString = challengeMatch[1];
|
||||
}
|
||||
@@ -1608,7 +1626,7 @@ class SMTPConnection extends EventEmitter {
|
||||
}
|
||||
|
||||
if (!this._envelope.rcptQueue.length) {
|
||||
return callback(this._formatError('Can\x27t send mail - no recipients defined', 'EENVELOPE', false, 'API'));
|
||||
return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API'));
|
||||
} else {
|
||||
this._recipientQueue = [];
|
||||
|
||||
@@ -1664,7 +1682,7 @@ class SMTPConnection extends EventEmitter {
|
||||
});
|
||||
this._sendCommand('DATA');
|
||||
} else {
|
||||
err = this._formatError('Can\x27t send mail - all recipients were rejected', 'EENVELOPE', str, 'RCPT TO');
|
||||
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', str, 'RCPT TO');
|
||||
err.rejected = this._envelope.rejected;
|
||||
err.rejectedErrors = this._envelope.rejectedErrors;
|
||||
return callback(err);
|
||||
@@ -1803,7 +1821,7 @@ class SMTPConnection extends EventEmitter {
|
||||
let defaultHostname;
|
||||
try {
|
||||
defaultHostname = os.hostname() || '';
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// fails on windows 7
|
||||
defaultHostname = 'localhost';
|
||||
}
|
||||
|
||||
4
backend/node_modules/nodemailer/lib/smtp-pool/index.js
generated
vendored
4
backend/node_modules/nodemailer/lib/smtp-pool/index.js
generated
vendored
@@ -406,6 +406,10 @@ class SMTPPool extends EventEmitter {
|
||||
this._continueProcessing();
|
||||
}, 50);
|
||||
} else {
|
||||
if (!this._closed && this.idling && !this._connections.length) {
|
||||
this.emit('clear');
|
||||
}
|
||||
|
||||
this._continueProcessing();
|
||||
}
|
||||
});
|
||||
|
||||
5
backend/node_modules/nodemailer/lib/smtp-pool/pool-resource.js
generated
vendored
5
backend/node_modules/nodemailer/lib/smtp-pool/pool-resource.js
generated
vendored
@@ -23,7 +23,8 @@ class PoolResource extends EventEmitter {
|
||||
switch ((this.options.auth.type || '').toString().toUpperCase()) {
|
||||
case 'OAUTH2': {
|
||||
let oauth2 = new XOAuth2(this.options.auth, this.logger);
|
||||
oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
|
||||
oauth2.provisionCallback =
|
||||
(this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
|
||||
this.auth = {
|
||||
type: 'OAUTH2',
|
||||
user: this.options.auth.user,
|
||||
@@ -127,7 +128,7 @@ class PoolResource extends EventEmitter {
|
||||
|
||||
try {
|
||||
timer.unref();
|
||||
} catch (E) {
|
||||
} catch (_E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
});
|
||||
|
||||
2
backend/node_modules/nodemailer/lib/smtp-transport/index.js
generated
vendored
2
backend/node_modules/nodemailer/lib/smtp-transport/index.js
generated
vendored
@@ -197,7 +197,7 @@ class SMTPTransport extends EventEmitter {
|
||||
|
||||
try {
|
||||
timer.unref();
|
||||
} catch (E) {
|
||||
} catch (_E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
});
|
||||
|
||||
361
backend/node_modules/nodemailer/lib/well-known/services.json
generated
vendored
361
backend/node_modules/nodemailer/lib/well-known/services.json
generated
vendored
@@ -1,63 +1,120 @@
|
||||
{
|
||||
"1und1": {
|
||||
"description": "1&1 Mail (German hosting provider)",
|
||||
"host": "smtp.1und1.de",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
|
||||
|
||||
"126": {
|
||||
"description": "126 Mail (NetEase)",
|
||||
"host": "smtp.126.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"163": {
|
||||
"description": "163 Mail (NetEase)",
|
||||
"host": "smtp.163.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Aliyun": {
|
||||
"description": "Alibaba Cloud Mail",
|
||||
"domains": ["aliyun.com"],
|
||||
"host": "smtp.aliyun.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
|
||||
"AliyunQiye": {
|
||||
"description": "Alibaba Cloud Enterprise Mail",
|
||||
"host": "smtp.qiye.aliyun.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"AOL": {
|
||||
"description": "AOL Mail",
|
||||
"domains": ["aol.com"],
|
||||
"host": "smtp.aol.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Aruba": {
|
||||
"description": "Aruba PEC (Italian email provider)",
|
||||
"domains": ["aruba.it", "pec.aruba.it"],
|
||||
"aliases": ["Aruba PEC"],
|
||||
"host": "smtps.aruba.it",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
|
||||
"Bluewin": {
|
||||
"description": "Bluewin (Swiss email provider)",
|
||||
"host": "smtpauths.bluewin.ch",
|
||||
"domains": ["bluewin.ch"],
|
||||
"port": 465
|
||||
},
|
||||
|
||||
"BOL": {
|
||||
"description": "BOL Mail (Brazilian provider)",
|
||||
"domains": ["bol.com.br"],
|
||||
"host": "smtp.bol.com.br",
|
||||
"port": 587,
|
||||
"requireTLS": true
|
||||
},
|
||||
|
||||
"DebugMail": {
|
||||
"description": "DebugMail (email testing service)",
|
||||
"host": "debugmail.io",
|
||||
"port": 25
|
||||
},
|
||||
|
||||
"Disroot": {
|
||||
"description": "Disroot (privacy-focused provider)",
|
||||
"domains": ["disroot.org"],
|
||||
"host": "disroot.org",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
|
||||
"DynectEmail": {
|
||||
"description": "Dyn Email Delivery",
|
||||
"aliases": ["Dynect"],
|
||||
"host": "smtp.dynect.net",
|
||||
"port": 25
|
||||
},
|
||||
|
||||
"ElasticEmail": {
|
||||
"description": "Elastic Email",
|
||||
"aliases": ["Elastic Email"],
|
||||
"host": "smtp.elasticemail.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Ethereal": {
|
||||
"description": "Ethereal Email (email testing service)",
|
||||
"aliases": ["ethereal.email"],
|
||||
"host": "smtp.ethereal.email",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"FastMail": {
|
||||
"description": "FastMail",
|
||||
"domains": ["fastmail.fm"],
|
||||
"host": "smtp.fastmail.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Forward Email": {
|
||||
"aliases": ["FE", "ForwardEmail"],
|
||||
"domains": ["forwardemail.net"],
|
||||
"host": "smtp.forwardemail.net",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Feishu Mail": {
|
||||
"description": "Feishu Mail (Lark)",
|
||||
"aliases": ["Feishu", "FeishuMail"],
|
||||
"domains": ["www.feishu.cn"],
|
||||
"host": "smtp.feishu.cn",
|
||||
@@ -65,13 +122,24 @@
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Forward Email": {
|
||||
"description": "Forward Email (email forwarding service)",
|
||||
"aliases": ["FE", "ForwardEmail"],
|
||||
"domains": ["forwardemail.net"],
|
||||
"host": "smtp.forwardemail.net",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"GandiMail": {
|
||||
"description": "Gandi Mail",
|
||||
"aliases": ["Gandi", "Gandi Mail"],
|
||||
"host": "mail.gandi.net",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Gmail": {
|
||||
"description": "Gmail",
|
||||
"aliases": ["Google Mail"],
|
||||
"domains": ["gmail.com", "googlemail.com"],
|
||||
"host": "smtp.gmail.com",
|
||||
@@ -79,26 +147,38 @@
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"GMX": {
|
||||
"description": "GMX Mail",
|
||||
"domains": ["gmx.com", "gmx.net", "gmx.de"],
|
||||
"host": "mail.gmx.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Godaddy": {
|
||||
"description": "GoDaddy Email (US)",
|
||||
"host": "smtpout.secureserver.net",
|
||||
"port": 25
|
||||
},
|
||||
|
||||
"GodaddyAsia": {
|
||||
"description": "GoDaddy Email (Asia)",
|
||||
"host": "smtp.asia.secureserver.net",
|
||||
"port": 25
|
||||
},
|
||||
|
||||
"GodaddyEurope": {
|
||||
"description": "GoDaddy Email (Europe)",
|
||||
"host": "smtp.europe.secureserver.net",
|
||||
"port": 25
|
||||
},
|
||||
|
||||
"hot.ee": {
|
||||
"description": "Hot.ee (Estonian email provider)",
|
||||
"host": "mail.hot.ee"
|
||||
},
|
||||
|
||||
"Hotmail": {
|
||||
"description": "Outlook.com / Hotmail",
|
||||
"aliases": ["Outlook", "Outlook.com", "Hotmail.com"],
|
||||
"domains": ["hotmail.com", "outlook.com"],
|
||||
"host": "smtp-mail.outlook.com",
|
||||
@@ -106,6 +186,7 @@
|
||||
},
|
||||
|
||||
"iCloud": {
|
||||
"description": "iCloud Mail",
|
||||
"aliases": ["Me", "Mac"],
|
||||
"domains": ["me.com", "mac.com"],
|
||||
"host": "smtp.mail.me.com",
|
||||
@@ -113,72 +194,117 @@
|
||||
},
|
||||
|
||||
"Infomaniak": {
|
||||
"description": "Infomaniak Mail (Swiss hosting provider)",
|
||||
"host": "mail.infomaniak.com",
|
||||
"domains": ["ik.me", "ikmail.com", "etik.com"],
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"KolabNow": {
|
||||
"description": "KolabNow (secure email service)",
|
||||
"domains": ["kolabnow.com"],
|
||||
"aliases": ["Kolab"],
|
||||
"host": "smtp.kolabnow.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
|
||||
"Loopia": {
|
||||
"description": "Loopia (Swedish hosting provider)",
|
||||
"host": "mailcluster.loopia.se",
|
||||
"port": 465
|
||||
},
|
||||
|
||||
"Loops": {
|
||||
"description": "Loops",
|
||||
"host": "smtp.loops.so",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"mail.ee": {
|
||||
"description": "Mail.ee (Estonian email provider)",
|
||||
"host": "smtp.mail.ee"
|
||||
},
|
||||
|
||||
"Mail.ru": {
|
||||
"description": "Mail.ru",
|
||||
"host": "smtp.mail.ru",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Mailcatch.app": {
|
||||
"description": "Mailcatch (email testing service)",
|
||||
"host": "sandbox-smtp.mailcatch.app",
|
||||
"port": 2525
|
||||
},
|
||||
|
||||
"Maildev": {
|
||||
"description": "MailDev (local email testing)",
|
||||
"port": 1025,
|
||||
"ignoreTLS": true
|
||||
},
|
||||
|
||||
"MailerSend": {
|
||||
"description": "MailerSend",
|
||||
"host": "smtp.mailersend.net",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Mailgun": {
|
||||
"description": "Mailgun",
|
||||
"host": "smtp.mailgun.org",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Mailjet": {
|
||||
"description": "Mailjet",
|
||||
"host": "in.mailjet.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Mailosaur": {
|
||||
"description": "Mailosaur (email testing service)",
|
||||
"host": "mailosaur.io",
|
||||
"port": 25
|
||||
},
|
||||
|
||||
"Mailtrap": {
|
||||
"description": "Mailtrap",
|
||||
"host": "live.smtp.mailtrap.io",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Mandrill": {
|
||||
"description": "Mandrill (by Mailchimp)",
|
||||
"host": "smtp.mandrillapp.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Naver": {
|
||||
"description": "Naver Mail (Korean email provider)",
|
||||
"host": "smtp.naver.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"OhMySMTP": {
|
||||
"description": "OhMySMTP (email delivery service)",
|
||||
"host": "smtp.ohmysmtp.com",
|
||||
"port": 587,
|
||||
"secure": false
|
||||
},
|
||||
|
||||
"One": {
|
||||
"description": "One.com Email",
|
||||
"host": "send.one.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"OpenMailBox": {
|
||||
"description": "OpenMailBox",
|
||||
"aliases": ["OMB", "openmailbox.org"],
|
||||
"host": "smtp.openmailbox.org",
|
||||
"port": 465,
|
||||
@@ -186,30 +312,37 @@
|
||||
},
|
||||
|
||||
"Outlook365": {
|
||||
"description": "Microsoft 365 / Office 365",
|
||||
"host": "smtp.office365.com",
|
||||
"port": 587,
|
||||
"secure": false
|
||||
},
|
||||
|
||||
"OhMySMTP": {
|
||||
"host": "smtp.ohmysmtp.com",
|
||||
"port": 587,
|
||||
"secure": false
|
||||
},
|
||||
|
||||
"Postmark": {
|
||||
"description": "Postmark",
|
||||
"aliases": ["PostmarkApp"],
|
||||
"host": "smtp.postmarkapp.com",
|
||||
"port": 2525
|
||||
},
|
||||
|
||||
"Proton": {
|
||||
"description": "Proton Mail",
|
||||
"aliases": ["ProtonMail", "Proton.me", "Protonmail.com", "Protonmail.ch"],
|
||||
"domains": ["proton.me", "protonmail.com", "pm.me", "protonmail.ch"],
|
||||
"host": "smtp.protonmail.ch",
|
||||
"port": 587,
|
||||
"requireTLS": true
|
||||
},
|
||||
|
||||
"qiye.aliyun": {
|
||||
"description": "Alibaba Mail Enterprise Edition",
|
||||
"host": "smtp.mxhichina.com",
|
||||
"port": "465",
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"QQ": {
|
||||
"description": "QQ Mail",
|
||||
"domains": ["qq.com"],
|
||||
"host": "smtp.qq.com",
|
||||
"port": 465,
|
||||
@@ -217,6 +350,7 @@
|
||||
},
|
||||
|
||||
"QQex": {
|
||||
"description": "QQ Enterprise Mail",
|
||||
"aliases": ["QQ Enterprise"],
|
||||
"domains": ["exmail.qq.com"],
|
||||
"host": "smtp.exmail.qq.com",
|
||||
@@ -224,89 +358,204 @@
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Resend": {
|
||||
"description": "Resend",
|
||||
"host": "smtp.resend.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Runbox": {
|
||||
"description": "Runbox (Norwegian email provider)",
|
||||
"domains": ["runbox.com"],
|
||||
"host": "smtp.runbox.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SendCloud": {
|
||||
"description": "SendCloud (Chinese email delivery)",
|
||||
"host": "smtp.sendcloud.net",
|
||||
"port": 2525
|
||||
},
|
||||
|
||||
"SendGrid": {
|
||||
"description": "SendGrid",
|
||||
"host": "smtp.sendgrid.net",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"SendinBlue": {
|
||||
"description": "Brevo (formerly Sendinblue)",
|
||||
"aliases": ["Brevo"],
|
||||
"host": "smtp-relay.brevo.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"SendPulse": {
|
||||
"description": "SendPulse",
|
||||
"host": "smtp-pulse.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES": {
|
||||
"description": "AWS SES US East (N. Virginia)",
|
||||
"host": "email-smtp.us-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-EAST-1": {
|
||||
"host": "email-smtp.us-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-WEST-2": {
|
||||
"host": "email-smtp.us-west-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-EU-WEST-1": {
|
||||
"host": "email-smtp.eu-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-SOUTH-1": {
|
||||
"host": "email-smtp.ap-south-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-NORTHEAST-1": {
|
||||
"description": "AWS SES Asia Pacific (Tokyo)",
|
||||
"host": "email-smtp.ap-northeast-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-NORTHEAST-2": {
|
||||
"description": "AWS SES Asia Pacific (Seoul)",
|
||||
"host": "email-smtp.ap-northeast-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-NORTHEAST-3": {
|
||||
"description": "AWS SES Asia Pacific (Osaka)",
|
||||
"host": "email-smtp.ap-northeast-3.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-SOUTH-1": {
|
||||
"description": "AWS SES Asia Pacific (Mumbai)",
|
||||
"host": "email-smtp.ap-south-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-SOUTHEAST-1": {
|
||||
"description": "AWS SES Asia Pacific (Singapore)",
|
||||
"host": "email-smtp.ap-southeast-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-AP-SOUTHEAST-2": {
|
||||
"description": "AWS SES Asia Pacific (Sydney)",
|
||||
"host": "email-smtp.ap-southeast-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-CA-CENTRAL-1": {
|
||||
"description": "AWS SES Canada (Central)",
|
||||
"host": "email-smtp.ca-central-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-EU-CENTRAL-1": {
|
||||
"description": "AWS SES Europe (Frankfurt)",
|
||||
"host": "email-smtp.eu-central-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-EU-NORTH-1": {
|
||||
"description": "AWS SES Europe (Stockholm)",
|
||||
"host": "email-smtp.eu-north-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-EU-WEST-1": {
|
||||
"description": "AWS SES Europe (Ireland)",
|
||||
"host": "email-smtp.eu-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-EU-WEST-2": {
|
||||
"description": "AWS SES Europe (London)",
|
||||
"host": "email-smtp.eu-west-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-EU-WEST-3": {
|
||||
"description": "AWS SES Europe (Paris)",
|
||||
"host": "email-smtp.eu-west-3.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-SA-EAST-1": {
|
||||
"description": "AWS SES South America (São Paulo)",
|
||||
"host": "email-smtp.sa-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-EAST-1": {
|
||||
"description": "AWS SES US East (N. Virginia)",
|
||||
"host": "email-smtp.us-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-EAST-2": {
|
||||
"description": "AWS SES US East (Ohio)",
|
||||
"host": "email-smtp.us-east-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-GOV-EAST-1": {
|
||||
"description": "AWS SES GovCloud (US-East)",
|
||||
"host": "email-smtp.us-gov-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-GOV-WEST-1": {
|
||||
"description": "AWS SES GovCloud (US-West)",
|
||||
"host": "email-smtp.us-gov-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-WEST-1": {
|
||||
"description": "AWS SES US West (N. California)",
|
||||
"host": "email-smtp.us-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SES-US-WEST-2": {
|
||||
"description": "AWS SES US West (Oregon)",
|
||||
"host": "email-smtp.us-west-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Seznam": {
|
||||
"description": "Seznam Email (Czech email provider)",
|
||||
"aliases": ["Seznam Email"],
|
||||
"domains": ["seznam.cz", "email.cz", "post.cz", "spoluzaci.cz"],
|
||||
"host": "smtp.seznam.cz",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"SMTP2GO": {
|
||||
"description": "SMTP2GO",
|
||||
"host": "mail.smtp2go.com",
|
||||
"port": 2525
|
||||
},
|
||||
|
||||
"Sparkpost": {
|
||||
"description": "SparkPost",
|
||||
"aliases": ["SparkPost", "SparkPost Mail"],
|
||||
"domains": ["sparkpost.com"],
|
||||
"host": "smtp.sparkpostmail.com",
|
||||
@@ -315,11 +564,21 @@
|
||||
},
|
||||
|
||||
"Tipimail": {
|
||||
"description": "Tipimail (email delivery service)",
|
||||
"host": "smtp.tipimail.com",
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"Tutanota": {
|
||||
"description": "Tutanota (Tuta Mail)",
|
||||
"domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"],
|
||||
"host": "smtp.tutanota.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Yahoo": {
|
||||
"description": "Yahoo Mail",
|
||||
"domains": ["yahoo.com"],
|
||||
"host": "smtp.mail.yahoo.com",
|
||||
"port": 465,
|
||||
@@ -327,28 +586,26 @@
|
||||
},
|
||||
|
||||
"Yandex": {
|
||||
"description": "Yandex Mail",
|
||||
"domains": ["yandex.ru"],
|
||||
"host": "smtp.yandex.ru",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Zimbra": {
|
||||
"description": "Zimbra Mail Server",
|
||||
"aliases": ["Zimbra Collaboration"],
|
||||
"host": "smtp.zimbra.com",
|
||||
"port": 587,
|
||||
"requireTLS": true
|
||||
},
|
||||
|
||||
"Zoho": {
|
||||
"description": "Zoho Mail",
|
||||
"host": "smtp.zoho.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
|
||||
"126": {
|
||||
"host": "smtp.126.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"163": {
|
||||
"host": "smtp.163.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
}
|
||||
}
|
||||
|
||||
65
backend/node_modules/nodemailer/lib/xoauth2/index.js
generated
vendored
65
backend/node_modules/nodemailer/lib/xoauth2/index.js
generated
vendored
@@ -72,6 +72,9 @@ class XOAuth2 extends Stream {
|
||||
let timeout = Math.max(Number(this.options.timeout) || 0, 0);
|
||||
this.expires = (timeout && Date.now() + timeout * 1000) || 0;
|
||||
}
|
||||
|
||||
this.renewing = false; // Track if renewal is in progress
|
||||
this.renewalQueue = []; // Queue for pending requests during renewal
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,14 +85,61 @@ class XOAuth2 extends Stream {
|
||||
*/
|
||||
getToken(renew, callback) {
|
||||
if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
|
||||
this.logger.debug(
|
||||
{
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'reuse'
|
||||
},
|
||||
'Reusing existing access token for %s',
|
||||
this.options.user
|
||||
);
|
||||
return callback(null, this.accessToken);
|
||||
}
|
||||
|
||||
let generateCallback = (...args) => {
|
||||
if (args[0]) {
|
||||
// check if it is possible to renew, if not, return the current token or error
|
||||
if (!this.provisionCallback && !this.options.refreshToken && !this.options.serviceClient) {
|
||||
if (this.accessToken) {
|
||||
this.logger.debug(
|
||||
{
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'reuse'
|
||||
},
|
||||
'Reusing existing access token (no refresh capability) for %s',
|
||||
this.options.user
|
||||
);
|
||||
return callback(null, this.accessToken);
|
||||
}
|
||||
this.logger.error(
|
||||
{
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'renew'
|
||||
},
|
||||
'Cannot renew access token for %s: No refresh mechanism available',
|
||||
this.options.user
|
||||
);
|
||||
return callback(new Error("Can't create new access token for user"));
|
||||
}
|
||||
|
||||
// If renewal already in progress, queue this request instead of starting another
|
||||
if (this.renewing) {
|
||||
return this.renewalQueue.push({ renew, callback });
|
||||
}
|
||||
|
||||
this.renewing = true;
|
||||
|
||||
// Handles token renewal completion - processes queued requests and cleans up
|
||||
const generateCallback = (err, accessToken) => {
|
||||
this.renewalQueue.forEach(item => item.callback(err, accessToken));
|
||||
this.renewalQueue = [];
|
||||
this.renewing = false;
|
||||
|
||||
if (err) {
|
||||
this.logger.error(
|
||||
{
|
||||
err: args[0],
|
||||
err,
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'renew'
|
||||
@@ -108,7 +158,8 @@ class XOAuth2 extends Stream {
|
||||
this.options.user
|
||||
);
|
||||
}
|
||||
callback(...args);
|
||||
// Complete original request
|
||||
callback(err, accessToken);
|
||||
};
|
||||
|
||||
if (this.provisionCallback) {
|
||||
@@ -166,8 +217,8 @@ class XOAuth2 extends Stream {
|
||||
let token;
|
||||
try {
|
||||
token = this.jwtSignRS256(tokenData);
|
||||
} catch (err) {
|
||||
return callback(new Error('Can\x27t generate token. Check your auth options'));
|
||||
} catch (_err) {
|
||||
return callback(new Error("Can't generate token. Check your auth options"));
|
||||
}
|
||||
|
||||
urlOptions = {
|
||||
@@ -181,7 +232,7 @@ class XOAuth2 extends Stream {
|
||||
};
|
||||
} else {
|
||||
if (!this.options.refreshToken) {
|
||||
return callback(new Error('Can\x27t create new access token for user'));
|
||||
return callback(new Error("Can't create new access token for user"));
|
||||
}
|
||||
|
||||
// web app - https://developers.google.com/identity/protocols/OAuth2WebServer
|
||||
|
||||
22
backend/node_modules/nodemailer/package.json
generated
vendored
22
backend/node_modules/nodemailer/package.json
generated
vendored
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "nodemailer",
|
||||
"version": "6.9.14",
|
||||
"version": "7.0.9",
|
||||
"description": "Easy as cake e-mail sending from your Node.js applications",
|
||||
"main": "lib/nodemailer.js",
|
||||
"scripts": {
|
||||
"test": "node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
|
||||
"test:coverage": "c8 node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
|
||||
"format": "prettier --write \"**/*.{js,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{js,json,md}\"",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"update": "rm -rf node_modules/ package-lock.json && ncu -u && npm install"
|
||||
},
|
||||
"repository": {
|
||||
@@ -23,19 +26,20 @@
|
||||
},
|
||||
"homepage": "https://nodemailer.com/",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-ses": "3.600.0",
|
||||
"@aws-sdk/client-sesv2": "3.901.0",
|
||||
"bunyan": "1.8.15",
|
||||
"c8": "10.1.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-nodemailer": "1.2.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"c8": "10.1.3",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^16.4.0",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.5",
|
||||
"libqp": "2.1.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer-ntlm-auth": "1.0.4",
|
||||
"prettier": "^3.6.2",
|
||||
"proxy": "1.0.2",
|
||||
"proxy-test-server": "1.0.0",
|
||||
"smtp-server": "3.13.4"
|
||||
"smtp-server": "3.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
19
backend/package-lock.json
generated
19
backend/package-lock.json
generated
@@ -22,7 +22,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.3",
|
||||
"nodemailer": "^6.9.14",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.9",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5"
|
||||
@@ -2827,6 +2828,15 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
@@ -2853,9 +2863,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.9.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
|
||||
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.3",
|
||||
"nodemailer": "^6.9.14",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.9",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5"
|
||||
|
||||
@@ -25,5 +25,8 @@ router.post('/verify', myTischtennisController.verifyLogin);
|
||||
// GET /api/mytischtennis/session - Get stored session
|
||||
router.get('/session', myTischtennisController.getSession);
|
||||
|
||||
// GET /api/mytischtennis/update-history - Get update ratings history
|
||||
router.get('/update-history', myTischtennisController.getUpdateHistory);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
|
||||
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -38,6 +38,7 @@ import teamRoutes from './routes/teamRoutes.js';
|
||||
import clubTeamRoutes from './routes/clubTeamRoutes.js';
|
||||
import teamDocumentRoutes from './routes/teamDocumentRoutes.js';
|
||||
import seasonRoutes from './routes/seasonRoutes.js';
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
@@ -187,9 +188,14 @@ app.get('*', (req, res) => {
|
||||
await safeSync(Accident);
|
||||
await safeSync(UserToken);
|
||||
await safeSync(MyTischtennis);
|
||||
await safeSync(MyTischtennisUpdateHistory);
|
||||
|
||||
// Start scheduler service
|
||||
schedulerService.start();
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on http://localhost:${port}`);
|
||||
console.log('Scheduler service started - Rating updates scheduled for 6:00 AM daily');
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Unable to synchronize the database:', err);
|
||||
|
||||
141
backend/services/autoUpdateRatingsService.js
Normal file
141
backend/services/autoUpdateRatingsService.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import myTischtennisService from './myTischtennisService.js';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
import MyTischtennis from '../models/MyTischtennis.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class AutoUpdateRatingsService {
|
||||
/**
|
||||
* Execute automatic rating updates for all users with enabled auto-updates
|
||||
*/
|
||||
async executeAutomaticUpdates() {
|
||||
devLog('Starting automatic rating updates...');
|
||||
|
||||
try {
|
||||
// Find all users with auto-updates enabled
|
||||
const accounts = await MyTischtennis.findAll({
|
||||
where: {
|
||||
autoUpdateRatings: true,
|
||||
savePassword: true // Must have saved password
|
||||
},
|
||||
attributes: ['id', 'userId', 'email', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
|
||||
});
|
||||
|
||||
devLog(`Found ${accounts.length} accounts with auto-updates enabled`);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
devLog('No accounts found with auto-updates enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each account
|
||||
for (const account of accounts) {
|
||||
await this.processAccount(account);
|
||||
}
|
||||
|
||||
devLog('Automatic rating updates completed');
|
||||
} catch (error) {
|
||||
console.error('Error in automatic rating updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single account for rating updates
|
||||
*/
|
||||
async processAccount(account) {
|
||||
const startTime = Date.now();
|
||||
let success = false;
|
||||
let message = '';
|
||||
let errorDetails = null;
|
||||
let updatedCount = 0;
|
||||
|
||||
try {
|
||||
devLog(`Processing account ${account.email} (User ID: ${account.userId})`);
|
||||
|
||||
// Check if session is still valid
|
||||
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
|
||||
devLog(`Session expired for ${account.email}, attempting re-login`);
|
||||
|
||||
// Try to re-login with stored password
|
||||
const password = account.getPassword();
|
||||
if (!password) {
|
||||
throw new Error('No stored password available for re-login');
|
||||
}
|
||||
|
||||
const loginResult = await myTischtennisClient.login(account.email, password);
|
||||
if (!loginResult.success) {
|
||||
throw new Error(`Re-login failed: ${loginResult.error}`);
|
||||
}
|
||||
|
||||
// Update session data
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
await account.save();
|
||||
|
||||
devLog(`Successfully re-logged in for ${account.email}`);
|
||||
}
|
||||
|
||||
// Perform rating update
|
||||
const updateResult = await this.updateRatings(account);
|
||||
updatedCount = updateResult.updatedCount || 0;
|
||||
|
||||
success = true;
|
||||
message = `Successfully updated ${updatedCount} ratings`;
|
||||
devLog(`Updated ${updatedCount} ratings for ${account.email}`);
|
||||
|
||||
} catch (error) {
|
||||
success = false;
|
||||
message = 'Update failed';
|
||||
errorDetails = error.message;
|
||||
console.error(`Error updating ratings for ${account.email}:`, error);
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
// Log the attempt
|
||||
await myTischtennisService.logUpdateAttempt(
|
||||
account.userId,
|
||||
success,
|
||||
message,
|
||||
errorDetails,
|
||||
updatedCount,
|
||||
executionTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ratings for a specific account
|
||||
*/
|
||||
async updateRatings(account) {
|
||||
// TODO: Implement actual rating update logic
|
||||
// This would typically involve:
|
||||
// 1. Fetching current ratings from myTischtennis
|
||||
// 2. Comparing with local data
|
||||
// 3. Updating local member ratings
|
||||
|
||||
devLog(`Updating ratings for ${account.email}`);
|
||||
|
||||
// For now, simulate an update
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount: Math.floor(Math.random() * 10) // Simulate some updates
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts with auto-updates enabled (for manual execution)
|
||||
*/
|
||||
async getAutoUpdateAccounts() {
|
||||
return await MyTischtennis.findAll({
|
||||
where: {
|
||||
autoUpdateRatings: true
|
||||
},
|
||||
attributes: ['userId', 'email', 'autoUpdateRatings', 'lastUpdateRatings']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AutoUpdateRatingsService();
|
||||
@@ -1,4 +1,5 @@
|
||||
import MyTischtennis from '../models/MyTischtennis.js';
|
||||
import MyTischtennisUpdateHistory from '../models/MyTischtennisUpdateHistory.js';
|
||||
import User from '../models/User.js';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
@@ -11,7 +12,7 @@ class MyTischtennisService {
|
||||
async getAccount(userId) {
|
||||
const account = await MyTischtennis.findOne({
|
||||
where: { userId },
|
||||
attributes: ['id', 'email', 'savePassword', 'lastLoginAttempt', 'lastLoginSuccess', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
|
||||
attributes: ['id', 'email', 'savePassword', 'autoUpdateRatings', 'lastLoginAttempt', 'lastLoginSuccess', 'lastUpdateRatings', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
|
||||
});
|
||||
return account;
|
||||
}
|
||||
@@ -19,7 +20,7 @@ class MyTischtennisService {
|
||||
/**
|
||||
* Create or update myTischtennis account
|
||||
*/
|
||||
async upsertAccount(userId, email, password, savePassword, userPassword) {
|
||||
async upsertAccount(userId, email, password, savePassword, autoUpdateRatings, userPassword) {
|
||||
// Verify user's app password
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
@@ -51,6 +52,7 @@ class MyTischtennisService {
|
||||
// Update existing
|
||||
account.email = email;
|
||||
account.savePassword = savePassword;
|
||||
account.autoUpdateRatings = autoUpdateRatings;
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
@@ -88,6 +90,7 @@ class MyTischtennisService {
|
||||
userId,
|
||||
email,
|
||||
savePassword,
|
||||
autoUpdateRatings,
|
||||
lastLoginAttempt: password ? now : null,
|
||||
lastLoginSuccess: loginResult?.success ? now : null
|
||||
};
|
||||
@@ -119,8 +122,10 @@ class MyTischtennisService {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
savePassword: account.savePassword,
|
||||
autoUpdateRatings: account.autoUpdateRatings,
|
||||
lastLoginAttempt: account.lastLoginAttempt,
|
||||
lastLoginSuccess: account.lastLoginSuccess,
|
||||
lastUpdateRatings: account.lastUpdateRatings,
|
||||
expiresAt: account.expiresAt
|
||||
};
|
||||
}
|
||||
@@ -235,6 +240,53 @@ class MyTischtennisService {
|
||||
userData: account.userData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update ratings history for user
|
||||
*/
|
||||
async getUpdateHistory(userId) {
|
||||
const history = await MyTischtennisUpdateHistory.findAll({
|
||||
where: { userId },
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 50 // Letzte 50 Einträge
|
||||
});
|
||||
|
||||
return history.map(entry => ({
|
||||
id: entry.id,
|
||||
success: entry.success,
|
||||
message: entry.message,
|
||||
errorDetails: entry.errorDetails,
|
||||
updatedCount: entry.updatedCount,
|
||||
executionTime: entry.executionTime,
|
||||
createdAt: entry.createdAt
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log update ratings attempt
|
||||
*/
|
||||
async logUpdateAttempt(userId, success, message, errorDetails = null, updatedCount = 0, executionTime = null) {
|
||||
try {
|
||||
await MyTischtennisUpdateHistory.create({
|
||||
userId,
|
||||
success,
|
||||
message,
|
||||
errorDetails,
|
||||
updatedCount,
|
||||
executionTime
|
||||
});
|
||||
|
||||
// Update lastUpdateRatings in main table
|
||||
if (success) {
|
||||
await MyTischtennis.update(
|
||||
{ lastUpdateRatings: new Date() },
|
||||
{ where: { userId } }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging update attempt:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisService();
|
||||
|
||||
108
backend/services/schedulerService.js
Normal file
108
backend/services/schedulerService.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import cron from 'node-cron';
|
||||
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class SchedulerService {
|
||||
constructor() {
|
||||
this.jobs = new Map();
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
devLog('Scheduler is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
devLog('Starting scheduler service...');
|
||||
|
||||
// Schedule automatic rating updates at 6:00 AM daily
|
||||
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
|
||||
devLog('Executing scheduled rating updates...');
|
||||
try {
|
||||
await autoUpdateRatingsService.executeAutomaticUpdates();
|
||||
} catch (error) {
|
||||
console.error('Error in scheduled rating updates:', error);
|
||||
}
|
||||
}, {
|
||||
scheduled: false, // Don't start automatically
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
ratingUpdateJob.start();
|
||||
|
||||
this.isRunning = true;
|
||||
devLog('Scheduler service started successfully');
|
||||
devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler
|
||||
*/
|
||||
stop() {
|
||||
if (!this.isRunning) {
|
||||
devLog('Scheduler is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
devLog('Stopping scheduler service...');
|
||||
|
||||
for (const [name, job] of this.jobs) {
|
||||
job.stop();
|
||||
devLog(`Stopped job: ${name}`);
|
||||
}
|
||||
|
||||
this.jobs.clear();
|
||||
this.isRunning = false;
|
||||
devLog('Scheduler service stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
jobs: Array.from(this.jobs.keys()),
|
||||
timezone: 'Europe/Berlin'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger rating updates (for testing)
|
||||
*/
|
||||
async triggerRatingUpdates() {
|
||||
devLog('Manually triggering rating updates...');
|
||||
try {
|
||||
await autoUpdateRatingsService.executeAutomaticUpdates();
|
||||
return { success: true, message: 'Rating updates completed successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error in manual rating updates:', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next scheduled execution time for rating updates
|
||||
*/
|
||||
getNextRatingUpdateTime() {
|
||||
const job = this.jobs.get('ratingUpdates');
|
||||
if (!job || !this.isRunning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get next execution time (this is a simplified approach)
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(6, 0, 0, 0);
|
||||
|
||||
return tomorrow;
|
||||
}
|
||||
}
|
||||
|
||||
export default new SchedulerService();
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"sortablejs": "^1.15.3",
|
||||
"vue": "^3.2.13",
|
||||
"vue-multiselect": "^3.0.0",
|
||||
@@ -2545,6 +2546,15 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"sortablejs": "^1.15.3",
|
||||
"vue": "^3.2.13",
|
||||
"vue-multiselect": "^3.0.0",
|
||||
|
||||
@@ -41,6 +41,24 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="formData.autoUpdateRatings"
|
||||
:disabled="!formData.savePassword"
|
||||
/>
|
||||
<span>Automatische Update-Ratings aktivieren</span>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Täglich um 6:00 Uhr werden automatisch die neuesten Ratings von myTischtennis abgerufen.
|
||||
<strong>Erfordert gespeichertes Passwort.</strong>
|
||||
</p>
|
||||
<p v-if="!formData.savePassword" class="warning">
|
||||
⚠️ Für automatische Updates muss das myTischtennis-Passwort gespeichert werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="formData.password">
|
||||
<label for="app-password">Ihr App-Passwort zur Bestätigung:</label>
|
||||
<input
|
||||
@@ -90,6 +108,7 @@ export default {
|
||||
email: this.account?.email || '',
|
||||
password: '',
|
||||
savePassword: this.account?.savePassword || false,
|
||||
autoUpdateRatings: this.account?.autoUpdateRatings || false,
|
||||
userPassword: ''
|
||||
},
|
||||
saving: false,
|
||||
@@ -108,6 +127,11 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Automatische Updates erfordern gespeichertes Passwort
|
||||
if (this.formData.autoUpdateRatings && !this.formData.savePassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
@@ -121,7 +145,8 @@ export default {
|
||||
try {
|
||||
const payload = {
|
||||
email: this.formData.email,
|
||||
savePassword: this.formData.savePassword
|
||||
savePassword: this.formData.savePassword,
|
||||
autoUpdateRatings: this.formData.autoUpdateRatings
|
||||
};
|
||||
|
||||
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
|
||||
@@ -243,6 +268,13 @@ export default {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem;
|
||||
background-color: #f8d7da;
|
||||
|
||||
228
frontend/src/components/MyTischtennisHistoryDialog.vue
Normal file
228
frontend/src/components/MyTischtennisHistoryDialog.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Update-Ratings History</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div v-if="loading" class="loading">
|
||||
Lade History...
|
||||
</div>
|
||||
|
||||
<div v-else-if="history.length === 0" class="no-history">
|
||||
<p>Noch keine automatischen Updates durchgeführt.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<div v-for="entry in history" :key="entry.id" class="history-entry">
|
||||
<div class="history-header">
|
||||
<span class="history-date">{{ formatDate(entry.createdAt) }}</span>
|
||||
<span class="history-status" :class="entry.success ? 'success' : 'error'">
|
||||
{{ entry.success ? 'Erfolgreich' : 'Fehlgeschlagen' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="entry.message" class="history-message">
|
||||
{{ entry.message }}
|
||||
</div>
|
||||
<div v-if="entry.errorDetails" class="history-error">
|
||||
{{ entry.errorDetails }}
|
||||
</div>
|
||||
<div v-if="entry.updatedCount !== undefined" class="history-stats">
|
||||
{{ entry.updatedCount }} Ratings aktualisiert
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="$emit('close')">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'MyTischtennisHistoryDialog',
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
history: []
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadHistory();
|
||||
},
|
||||
methods: {
|
||||
async loadHistory() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await apiClient.get('/mytischtennis/update-history');
|
||||
this.history = response.data.history || [];
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der History:', error);
|
||||
this.history = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.no-history {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-status.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.history-status.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.history-message {
|
||||
margin-top: 0.5rem;
|
||||
color: #495057;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
margin-top: 0.5rem;
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-stats {
|
||||
margin-top: 0.5rem;
|
||||
color: #28a745;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
</style>
|
||||
@@ -34,9 +34,20 @@
|
||||
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.lastUpdateRatings">
|
||||
<label>Letzter Abruf:</label>
|
||||
<span>{{ formatDate(account.lastUpdateRatings) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.autoUpdateRatings !== undefined">
|
||||
<label>Automatische Updates:</label>
|
||||
<span>{{ account.autoUpdateRatings ? 'Aktiviert' : 'Deaktiviert' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn-primary" @click="openEditDialog">Account bearbeiten</button>
|
||||
<button class="btn-secondary" @click="testConnection">Erneut einloggen</button>
|
||||
<button class="btn-info" @click="openHistoryDialog" v-if="account">Update-History</button>
|
||||
<button class="btn-danger" @click="deleteAccount">Account trennen</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,6 +77,12 @@
|
||||
@close="closeDialog"
|
||||
@saved="onAccountSaved"
|
||||
/>
|
||||
|
||||
<!-- History Dialog -->
|
||||
<MyTischtennisHistoryDialog
|
||||
v-if="showHistoryDialog"
|
||||
@close="closeHistoryDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -93,16 +110,18 @@
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import MyTischtennisDialog from '../components/MyTischtennisDialog.vue';
|
||||
import MyTischtennisHistoryDialog from '../components/MyTischtennisHistoryDialog.vue';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
export default {
|
||||
name: 'MyTischtennisAccount',
|
||||
components: {
|
||||
MyTischtennisDialog
|
||||
,
|
||||
InfoDialog,
|
||||
ConfirmDialog},
|
||||
MyTischtennisDialog,
|
||||
MyTischtennisHistoryDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
@@ -123,7 +142,8 @@ export default {
|
||||
},
|
||||
loading: true,
|
||||
account: null,
|
||||
showDialog: false
|
||||
showDialog: false,
|
||||
showHistoryDialog: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -186,6 +206,14 @@ export default {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
openHistoryDialog() {
|
||||
this.showHistoryDialog = true;
|
||||
},
|
||||
|
||||
closeHistoryDialog() {
|
||||
this.showHistoryDialog = false;
|
||||
},
|
||||
|
||||
async onAccountSaved() {
|
||||
this.closeDialog();
|
||||
await this.loadAccount();
|
||||
@@ -383,5 +411,14 @@ h1 {
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user