@opentermsarchive/engine 10.3.2 → 10.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,7 +43,8 @@
43
43
  },
44
44
  "logger": {
45
45
  "smtp": {
46
- "host": "smtp-relay.sendinblue.com",
46
+ "host": "smtp-relay.brevo.com",
47
+ "port": 587,
47
48
  "username": "admin@opentermsarchive.org"
48
49
  },
49
50
  "sendMailOnError": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "10.3.2",
3
+ "version": "10.4.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -30,7 +30,7 @@ export default async function publish({ archivePath, releaseDate, stats }) {
30
30
  logger.info('Uploading release asset…');
31
31
 
32
32
  await octokit.rest.repos.uploadReleaseAsset({
33
- data: fsApi.readFileSync(archivePath),
33
+ data: fsApi.createReadStream(archivePath),
34
34
  headers: {
35
35
  'content-type': 'application/zip',
36
36
  'content-length': fsApi.statSync(archivePath).size,
@@ -22,26 +22,29 @@ export default async function publishRelease({ archivePath, releaseDate, stats }
22
22
  throw new Error('No publishing platform configured. Please configure at least one of: GitHub (OTA_ENGINE_GITHUB_TOKEN), GitLab (OTA_ENGINE_GITLAB_TOKEN), or data.gouv.fr (OTA_ENGINE_DATAGOUV_API_KEY + datasetId or organizationIdOrSlug in config).');
23
23
  }
24
24
 
25
- const results = await Promise.allSettled(platforms.map(async platform => {
26
- const url = await platform.publish();
27
-
28
- return { platform: platform.name, url };
29
- }));
30
-
31
- const succeeded = results.filter(result => result.status === 'fulfilled');
32
- const failed = results.filter(result => result.status === 'rejected');
25
+ const succeeded = [];
26
+ const failed = [];
27
+
28
+ // Execute publications sequentially to avoid memory issues with large file uploads
29
+ for (const platform of platforms) {
30
+ try {
31
+ const url = await platform.publish();
32
+
33
+ succeeded.push({ platform: platform.name, url });
34
+ } catch (error) {
35
+ failed.push({ platform: platform.name, error });
36
+ }
37
+ }
33
38
 
34
39
  if (failed.length) {
35
40
  let errorMessage = !succeeded.length ? 'All platforms failed to publish:' : 'Some platforms failed to publish:';
36
41
 
37
- failed.forEach(rejectedResult => {
38
- const index = results.indexOf(rejectedResult);
39
-
40
- errorMessage += `\n - ${platforms[index].name}: ${rejectedResult.reason.message}`;
42
+ failed.forEach(({ platform, error }) => {
43
+ errorMessage += `\n - ${platform}: ${error.message}`;
41
44
  });
42
45
 
43
46
  logger.error(errorMessage);
44
47
  }
45
48
 
46
- return succeeded.map(result => result.value);
49
+ return succeeded;
47
50
  }
@@ -3,7 +3,8 @@ import os from 'os';
3
3
  import config from 'config';
4
4
  import dotenv from 'dotenv';
5
5
  import winston from 'winston';
6
- import 'winston-mail';
6
+
7
+ import MailTransportWithRetry from '../logger/mail-transport-with-retry.js';
7
8
 
8
9
  dotenv.config({ quiet: true });
9
10
 
@@ -12,14 +13,15 @@ const { combine, timestamp, printf, colorize } = winston.format;
12
13
  const transports = [new winston.transports.Console()];
13
14
 
14
15
  if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
15
- transports.push(new winston.transports.Mail({
16
+ transports.push(new MailTransportWithRetry({
16
17
  to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
17
18
  from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
18
19
  host: config.get('@opentermsarchive/engine.logger.smtp.host'),
20
+ port: config.get('@opentermsarchive/engine.logger.smtp.port'),
19
21
  username: config.get('@opentermsarchive/engine.logger.smtp.username'),
20
22
  password: process.env.OTA_ENGINE_SMTP_PASSWORD,
21
- ssl: true,
22
- timeout: 30 * 1000,
23
+ tls: true,
24
+ timeout: 60 * 1000,
23
25
  formatter: args => args[Object.getOwnPropertySymbols(args)[1]], // Returns the full error message, the same visible in the console. It is referenced in the argument object with a Symbol of which we do not have the reference but we know it is the second one.
24
26
  exitOnError: true,
25
27
  level: 'error',
@@ -2,10 +2,10 @@ import os from 'os';
2
2
 
3
3
  import config from 'config';
4
4
  import winston from 'winston';
5
- import 'winston-mail';
6
5
 
7
6
  import { getCollection } from '../archivist/collection/index.js';
8
7
 
8
+ import MailTransportWithRetry from './mail-transport-with-retry.js';
9
9
  import { formatDuration } from './utils.js';
10
10
 
11
11
  const { combine, timestamp, printf, colorize } = winston.format;
@@ -57,10 +57,11 @@ if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
57
57
  to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
58
58
  from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
59
59
  host: config.get('@opentermsarchive/engine.logger.smtp.host'),
60
+ port: config.get('@opentermsarchive/engine.logger.smtp.port'),
60
61
  username: config.get('@opentermsarchive/engine.logger.smtp.username'),
61
62
  password: process.env.OTA_ENGINE_SMTP_PASSWORD,
62
- ssl: true,
63
- timeout: 30 * 1000,
63
+ tls: true,
64
+ timeout: 60 * 1000,
64
65
  html: false,
65
66
  formatter({ message, level }) {
66
67
  const isError = level.includes('error');
@@ -141,14 +142,14 @@ if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
141
142
  },
142
143
  };
143
144
 
144
- transports.push(new winston.transports.Mail({
145
+ transports.push(new MailTransportWithRetry({
145
146
  ...mailerOptions,
146
147
  level: 'error',
147
148
  subject: `Server error on ${collection.id} collection`,
148
149
  }));
149
150
 
150
151
  if (config.get('@opentermsarchive/engine.logger.sendMailOnError.sendWarnings')) {
151
- transports.push(new winston.transports.Mail({
152
+ transports.push(new MailTransportWithRetry({
152
153
  ...mailerOptions,
153
154
  level: 'warn',
154
155
  subject: `Inaccessible content on ${collection.id} collection`,
@@ -0,0 +1,46 @@
1
+ import { once } from 'node:events';
2
+
3
+ import async from 'async';
4
+ import winston from 'winston';
5
+
6
+ import 'winston-mail';
7
+
8
+ export const RETRY_DELAYS = [ 5000, 20000, 60000 ];
9
+
10
+ const RETRY_OPTIONS = {
11
+ times: RETRY_DELAYS.length + 1,
12
+ interval: retryCount => RETRY_DELAYS[retryCount - 1] || RETRY_DELAYS.at(-1),
13
+ errorFilter: error => {
14
+ console.warn(`SMTP mail sending failed: ${error.message}; retrying…`);
15
+
16
+ return true;
17
+ },
18
+ };
19
+
20
+ class MailTransportWithRetry extends winston.Transport {
21
+ constructor(options) {
22
+ super(options);
23
+ this.mailTransport = new winston.transports.Mail(options);
24
+ }
25
+
26
+ async log(info, callback) {
27
+ try {
28
+ await async.retry(RETRY_OPTIONS, async () => {
29
+ const result = Promise.race([
30
+ once(this.mailTransport, 'logged'),
31
+ once(this.mailTransport, 'error').then(([err]) => { throw err; }),
32
+ ]);
33
+
34
+ this.mailTransport.log(info, () => {});
35
+
36
+ return result;
37
+ });
38
+ } catch (error) {
39
+ console.warn(`SMTP mail sending failed after ${RETRY_OPTIONS.times} attempts: ${error.message}`);
40
+ this.emit('error', error);
41
+ }
42
+ callback();
43
+ }
44
+ }
45
+
46
+ export default MailTransportWithRetry;
@@ -0,0 +1,192 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ import { expect } from 'chai';
4
+ import sinon from 'sinon';
5
+ import winston from 'winston';
6
+
7
+ import MailTransportWithRetry, { RETRY_DELAYS } from './mail-transport-with-retry.js';
8
+
9
+ class MockMailTransport extends EventEmitter {
10
+ log() {} // eslint-disable-line
11
+ }
12
+
13
+ describe('MailTransportWithRetry', () => {
14
+ let clock;
15
+ let mockMailTransport;
16
+ let transport;
17
+ let consoleWarnStub;
18
+
19
+ beforeEach(() => {
20
+ clock = sinon.useFakeTimers();
21
+ mockMailTransport = new MockMailTransport();
22
+ sinon.stub(winston.transports, 'Mail').returns(mockMailTransport);
23
+ consoleWarnStub = sinon.stub(console, 'warn');
24
+ transport = new MailTransportWithRetry({ to: 'test@example.com' });
25
+ });
26
+
27
+ afterEach(() => {
28
+ sinon.restore();
29
+ clock.restore();
30
+ });
31
+
32
+ describe('#log', () => {
33
+ context('when email is sent successfully on first attempt', () => {
34
+ it('calls callback without error', async () => {
35
+ const callback = sinon.spy();
36
+ const logPromise = transport.log({ message: 'test' }, callback);
37
+
38
+ mockMailTransport.emit('logged');
39
+ await logPromise;
40
+
41
+ expect(callback).to.have.been.calledOnce;
42
+ });
43
+
44
+ it('does not emit error event', async () => {
45
+ const errorHandler = sinon.spy();
46
+
47
+ transport.on('error', errorHandler);
48
+
49
+ const logPromise = transport.log({ message: 'test' }, () => {});
50
+
51
+ mockMailTransport.emit('logged');
52
+ await logPromise;
53
+
54
+ expect(errorHandler).not.to.have.been.called;
55
+ });
56
+
57
+ it('does not log any warning', async () => {
58
+ const logPromise = transport.log({ message: 'test' }, () => {});
59
+
60
+ mockMailTransport.emit('logged');
61
+ await logPromise;
62
+
63
+ expect(consoleWarnStub).not.to.have.been.called;
64
+ });
65
+ });
66
+
67
+ context('when email fails then succeeds on retry', () => {
68
+ it('retries and eventually succeeds', async () => {
69
+ const callback = sinon.spy();
70
+ const logSpy = sinon.spy(mockMailTransport, 'log');
71
+ const logPromise = transport.log({ message: 'test' }, callback);
72
+
73
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
74
+
75
+ await clock.tickAsync(RETRY_DELAYS[0]);
76
+
77
+ mockMailTransport.emit('logged');
78
+ await logPromise;
79
+
80
+ expect(logSpy).to.have.been.calledTwice;
81
+ expect(callback).to.have.been.calledOnce;
82
+ });
83
+
84
+ it('logs warning for failed attempt', async () => {
85
+ const logPromise = transport.log({ message: 'test' }, () => {});
86
+
87
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
88
+
89
+ await clock.tickAsync(RETRY_DELAYS[0]);
90
+
91
+ mockMailTransport.emit('logged');
92
+ await logPromise;
93
+
94
+ expect(consoleWarnStub).to.have.been.calledOnce;
95
+ expect(consoleWarnStub.firstCall.args[0]).to.include('SMTP mail sending failed');
96
+ expect(consoleWarnStub.firstCall.args[0]).to.include('SMTP timeout');
97
+ });
98
+ });
99
+
100
+ context('when email fails after all retry attempts', () => {
101
+ it('emits error event after all retries are exhausted', async () => {
102
+ const errorHandler = sinon.spy();
103
+
104
+ transport.on('error', errorHandler);
105
+
106
+ const logPromise = transport.log({ message: 'test' }, () => {});
107
+
108
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
109
+ await clock.tickAsync(RETRY_DELAYS[0]);
110
+
111
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
112
+ await clock.tickAsync(RETRY_DELAYS[1]);
113
+
114
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
115
+ await clock.tickAsync(RETRY_DELAYS[2]);
116
+
117
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
118
+ await logPromise;
119
+
120
+ expect(errorHandler).to.have.been.calledOnce;
121
+ expect(errorHandler.firstCall.args[0].message).to.equal('SMTP timeout');
122
+ });
123
+
124
+ it('calls callback even after failure', async () => {
125
+ const callback = sinon.spy();
126
+
127
+ transport.on('error', () => {}); // Prevent unhandled error
128
+
129
+ const logPromise = transport.log({ message: 'test' }, callback);
130
+
131
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
132
+ await clock.tickAsync(RETRY_DELAYS[0]);
133
+
134
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
135
+ await clock.tickAsync(RETRY_DELAYS[1]);
136
+
137
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
138
+ await clock.tickAsync(RETRY_DELAYS[2]);
139
+
140
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
141
+ await logPromise;
142
+
143
+ expect(callback).to.have.been.calledOnce;
144
+ });
145
+
146
+ it('logs final failure warning', async () => {
147
+ transport.on('error', () => {}); // Prevent unhandled error
148
+
149
+ const logPromise = transport.log({ message: 'test' }, () => {});
150
+
151
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
152
+ await clock.tickAsync(RETRY_DELAYS[0]);
153
+
154
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
155
+ await clock.tickAsync(RETRY_DELAYS[1]);
156
+
157
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
158
+ await clock.tickAsync(RETRY_DELAYS[2]);
159
+
160
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
161
+ await logPromise;
162
+
163
+ expect(consoleWarnStub.lastCall.args[0]).to.include(`failed after ${RETRY_DELAYS.length + 1} attempts`);
164
+ });
165
+ });
166
+
167
+ context('when email succeeds after multiple failures', () => {
168
+ it('succeeds on third attempt', async () => {
169
+ const callback = sinon.spy();
170
+ const errorHandler = sinon.spy();
171
+ const logSpy = sinon.spy(mockMailTransport, 'log');
172
+
173
+ transport.on('error', errorHandler);
174
+
175
+ const logPromise = transport.log({ message: 'test' }, callback);
176
+
177
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
178
+ await clock.tickAsync(RETRY_DELAYS[0]);
179
+
180
+ mockMailTransport.emit('error', new Error('Connection refused'));
181
+ await clock.tickAsync(RETRY_DELAYS[1]);
182
+
183
+ mockMailTransport.emit('logged');
184
+ await logPromise;
185
+
186
+ expect(logSpy).to.have.been.calledThrice;
187
+ expect(callback).to.have.been.calledOnce;
188
+ expect(errorHandler).not.to.have.been.called;
189
+ });
190
+ });
191
+ });
192
+ });