emailengine-app 2.68.1 → 2.69.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.
- package/.github/workflows/deploy.yml +2 -0
- package/.github/workflows/release.yaml +4 -0
- package/CHANGELOG.md +40 -0
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +7 -1
- package/lib/account.js +62 -25
- package/lib/api-routes/account-routes.js +493 -75
- package/lib/api-routes/blocklist-routes.js +337 -0
- package/lib/api-routes/delivery-test-routes.js +321 -0
- package/lib/api-routes/export-routes.js +1 -12
- package/lib/api-routes/gateway-routes.js +376 -0
- package/lib/api-routes/license-routes.js +142 -0
- package/lib/api-routes/mailbox-routes.js +318 -0
- package/lib/api-routes/message-routes.js +21 -129
- package/lib/api-routes/oauth2-app-routes.js +631 -0
- package/lib/api-routes/outbox-routes.js +173 -0
- package/lib/api-routes/pubsub-routes.js +98 -0
- package/lib/api-routes/route-helpers.js +45 -0
- package/lib/api-routes/settings-routes.js +331 -0
- package/lib/api-routes/stats-routes.js +77 -0
- package/lib/api-routes/submit-routes.js +472 -0
- package/lib/api-routes/template-routes.js +7 -55
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +152 -0
- package/lib/email-client/gmail-client.js +14 -0
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -12
- package/lib/email-client/imap/sync-operations.js +130 -2
- package/lib/email-client/imap-client.js +116 -58
- package/lib/email-client/outlook-client.js +85 -13
- package/lib/export.js +60 -19
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
- package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -23
- package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/message-port-stream.js +113 -16
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +26 -1
- package/lib/tools.js +68 -0
- package/lib/ui-routes/account-routes.js +40 -210
- package/lib/ui-routes/admin-config-routes.js +913 -487
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
- package/lib/ui-routes/route-helpers.js +316 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +234 -0
- package/lib/webhook-request.js +36 -0
- package/package.json +8 -8
- package/sbom.json +1 -1
- package/server.js +214 -16
- package/static/licenses.html +12 -12
- package/translations/messages.pot +129 -149
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +198 -4401
- package/workers/export.js +87 -54
- package/workers/imap.js +29 -13
- package/workers/submit.js +20 -11
- package/workers/webhooks.js +6 -20
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Joi = require('joi');
|
|
4
|
+
const { redis } = require('../db');
|
|
5
|
+
const { Account } = require('../account');
|
|
6
|
+
const getSecret = require('../get-secret');
|
|
7
|
+
const tokens = require('../tokens');
|
|
8
|
+
const { failAction } = require('../tools');
|
|
9
|
+
const { handleError } = require('./route-helpers');
|
|
10
|
+
const { accountIdSchema, tokenRestrictionsSchema, ipSchema, tokenIdSchema } = require('../schemas');
|
|
11
|
+
|
|
12
|
+
async function init(args) {
|
|
13
|
+
const { server, call, CORS_CONFIG } = args;
|
|
14
|
+
|
|
15
|
+
server.route({
|
|
16
|
+
method: 'POST',
|
|
17
|
+
path: '/v1/token',
|
|
18
|
+
|
|
19
|
+
async handler(request) {
|
|
20
|
+
let accountObject = new Account({
|
|
21
|
+
redis,
|
|
22
|
+
account: request.payload.account,
|
|
23
|
+
call,
|
|
24
|
+
secret: await getSecret(),
|
|
25
|
+
timeout: request.headers['x-ee-timeout']
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
// throws if account does not exist
|
|
30
|
+
await accountObject.loadAccountData();
|
|
31
|
+
|
|
32
|
+
let token = await tokens.provision(Object.assign({}, request.payload, { remoteAddress: request.app.ip }));
|
|
33
|
+
|
|
34
|
+
return { token };
|
|
35
|
+
} catch (err) {
|
|
36
|
+
handleError(request, err);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
options: {
|
|
41
|
+
description: 'Provision an access token',
|
|
42
|
+
notes: 'Provisions a new access token for an account',
|
|
43
|
+
tags: ['api', 'Access Tokens'],
|
|
44
|
+
|
|
45
|
+
plugins: {},
|
|
46
|
+
|
|
47
|
+
auth: {
|
|
48
|
+
strategy: 'api-token',
|
|
49
|
+
mode: 'required'
|
|
50
|
+
},
|
|
51
|
+
cors: CORS_CONFIG,
|
|
52
|
+
|
|
53
|
+
validate: {
|
|
54
|
+
options: {
|
|
55
|
+
stripUnknown: false,
|
|
56
|
+
abortEarly: false,
|
|
57
|
+
convert: true
|
|
58
|
+
},
|
|
59
|
+
failAction,
|
|
60
|
+
|
|
61
|
+
payload: Joi.object({
|
|
62
|
+
account: accountIdSchema.required(),
|
|
63
|
+
|
|
64
|
+
description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
|
|
65
|
+
|
|
66
|
+
scopes: Joi.array()
|
|
67
|
+
.items(Joi.string().valid('api', 'smtp', 'imap-proxy').label('TokenScope'))
|
|
68
|
+
.single()
|
|
69
|
+
.default(['api'])
|
|
70
|
+
.required()
|
|
71
|
+
.description(
|
|
72
|
+
'Token permission scopes: "api" for REST API access, "smtp" for SMTP submission, "imap-proxy" for IMAP proxy authentication'
|
|
73
|
+
)
|
|
74
|
+
.label('Scopes'),
|
|
75
|
+
|
|
76
|
+
metadata: Joi.string()
|
|
77
|
+
.empty('')
|
|
78
|
+
.max(1024 * 1024)
|
|
79
|
+
.custom((value, helpers) => {
|
|
80
|
+
try {
|
|
81
|
+
// check if parsing fails
|
|
82
|
+
JSON.parse(value);
|
|
83
|
+
return value;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return helpers.message('Metadata must be a valid JSON string');
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
.example('{"example": "value"}')
|
|
89
|
+
.description('Related metadata in JSON format')
|
|
90
|
+
.label('JsonMetaData'),
|
|
91
|
+
|
|
92
|
+
restrictions: tokenRestrictionsSchema,
|
|
93
|
+
|
|
94
|
+
ip: ipSchema.description('IP address of the requester').label('TokenIP')
|
|
95
|
+
}).label('CreateToken')
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
response: {
|
|
99
|
+
schema: Joi.object({
|
|
100
|
+
token: Joi.string().length(64).hex().required().example('123456').description('Access token')
|
|
101
|
+
}).label('CreateTokenResponse'),
|
|
102
|
+
failAction: 'log'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.route({
|
|
108
|
+
method: 'DELETE',
|
|
109
|
+
path: '/v1/token/{token}',
|
|
110
|
+
|
|
111
|
+
async handler(request) {
|
|
112
|
+
try {
|
|
113
|
+
return { deleted: await tokens.delete(request.params.token, { remoteAddress: request.app.ip }) };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
handleError(request, err);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
options: {
|
|
119
|
+
description: 'Remove a token',
|
|
120
|
+
notes: 'Delete an access token',
|
|
121
|
+
tags: ['api', 'Access Tokens'],
|
|
122
|
+
|
|
123
|
+
plugins: {},
|
|
124
|
+
|
|
125
|
+
auth: {
|
|
126
|
+
strategy: 'api-token',
|
|
127
|
+
mode: 'required'
|
|
128
|
+
},
|
|
129
|
+
cors: CORS_CONFIG,
|
|
130
|
+
|
|
131
|
+
validate: {
|
|
132
|
+
options: {
|
|
133
|
+
stripUnknown: false,
|
|
134
|
+
abortEarly: false,
|
|
135
|
+
convert: true
|
|
136
|
+
},
|
|
137
|
+
failAction,
|
|
138
|
+
|
|
139
|
+
params: Joi.object({
|
|
140
|
+
token: Joi.string().length(64).hex().required().example('123456').description('Access token')
|
|
141
|
+
}).label('DeleteTokenRequest')
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
response: {
|
|
145
|
+
schema: Joi.object({
|
|
146
|
+
deleted: Joi.boolean().truthy('Y', 'true', '1').falsy('N', 'false', 0).default(true).description('Was the token deleted')
|
|
147
|
+
}).label('DeleteTokenRequestResponse'),
|
|
148
|
+
failAction: 'log'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
server.route({
|
|
154
|
+
method: 'GET',
|
|
155
|
+
path: '/v1/tokens',
|
|
156
|
+
|
|
157
|
+
async handler(request) {
|
|
158
|
+
try {
|
|
159
|
+
// TODO: allow paging
|
|
160
|
+
return { tokens: (await tokens.list(null, 0, 1000)).tokens };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
handleError(request, err);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
options: {
|
|
167
|
+
description: 'List root tokens',
|
|
168
|
+
notes: 'Lists access tokens registered for root access',
|
|
169
|
+
tags: ['api', 'Access Tokens'],
|
|
170
|
+
|
|
171
|
+
plugins: {},
|
|
172
|
+
|
|
173
|
+
auth: {
|
|
174
|
+
strategy: 'api-token',
|
|
175
|
+
mode: 'required'
|
|
176
|
+
},
|
|
177
|
+
cors: CORS_CONFIG,
|
|
178
|
+
|
|
179
|
+
validate: {
|
|
180
|
+
options: {
|
|
181
|
+
stripUnknown: false,
|
|
182
|
+
abortEarly: false,
|
|
183
|
+
convert: true
|
|
184
|
+
},
|
|
185
|
+
failAction
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
response: {
|
|
189
|
+
schema: Joi.object({
|
|
190
|
+
tokens: Joi.array()
|
|
191
|
+
.items(
|
|
192
|
+
Joi.object({
|
|
193
|
+
account: accountIdSchema.required(),
|
|
194
|
+
description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
|
|
195
|
+
metadata: Joi.string()
|
|
196
|
+
.empty('')
|
|
197
|
+
.max(1024 * 1024)
|
|
198
|
+
.custom((value, helpers) => {
|
|
199
|
+
try {
|
|
200
|
+
// check if parsing fails
|
|
201
|
+
JSON.parse(value);
|
|
202
|
+
return value;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return helpers.message('Metadata must be a valid JSON string');
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
.example('{"example": "value"}')
|
|
208
|
+
.description('Related metadata in JSON format')
|
|
209
|
+
.label('JsonMetaData'),
|
|
210
|
+
ip: ipSchema.description('IP address of the requester').label('TokenIP'),
|
|
211
|
+
id: tokenIdSchema
|
|
212
|
+
}).label('RootTokensItem')
|
|
213
|
+
)
|
|
214
|
+
.label('RootTokensEntries')
|
|
215
|
+
}).label('RootTokensResponse'),
|
|
216
|
+
failAction: 'log'
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
server.route({
|
|
222
|
+
method: 'GET',
|
|
223
|
+
path: '/v1/tokens/account/{account}',
|
|
224
|
+
|
|
225
|
+
async handler(request) {
|
|
226
|
+
try {
|
|
227
|
+
// TODO: allow paging
|
|
228
|
+
return { tokens: (await tokens.list(request.params.account, 0, 1000)).tokens };
|
|
229
|
+
} catch (err) {
|
|
230
|
+
handleError(request, err);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
options: {
|
|
235
|
+
description: 'List account tokens',
|
|
236
|
+
notes: 'Lists access tokens registered for an account',
|
|
237
|
+
tags: ['api', 'Access Tokens'],
|
|
238
|
+
|
|
239
|
+
plugins: {},
|
|
240
|
+
|
|
241
|
+
auth: {
|
|
242
|
+
strategy: 'api-token',
|
|
243
|
+
mode: 'required'
|
|
244
|
+
},
|
|
245
|
+
cors: CORS_CONFIG,
|
|
246
|
+
|
|
247
|
+
validate: {
|
|
248
|
+
options: {
|
|
249
|
+
stripUnknown: false,
|
|
250
|
+
abortEarly: false,
|
|
251
|
+
convert: true
|
|
252
|
+
},
|
|
253
|
+
failAction,
|
|
254
|
+
params: Joi.object({
|
|
255
|
+
account: accountIdSchema.required()
|
|
256
|
+
})
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
response: {
|
|
260
|
+
schema: Joi.object({
|
|
261
|
+
tokens: Joi.array()
|
|
262
|
+
.items(
|
|
263
|
+
Joi.object({
|
|
264
|
+
account: accountIdSchema.required(),
|
|
265
|
+
description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
|
|
266
|
+
metadata: Joi.string()
|
|
267
|
+
.empty('')
|
|
268
|
+
.max(1024 * 1024)
|
|
269
|
+
.custom((value, helpers) => {
|
|
270
|
+
try {
|
|
271
|
+
// check if parsing fails
|
|
272
|
+
JSON.parse(value);
|
|
273
|
+
return value;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return helpers.message('Metadata must be a valid JSON string');
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
.example('{"example": "value"}')
|
|
279
|
+
.description('Related metadata in JSON format')
|
|
280
|
+
.label('JsonMetaData'),
|
|
281
|
+
|
|
282
|
+
restrictions: tokenRestrictionsSchema,
|
|
283
|
+
|
|
284
|
+
ip: ipSchema.description('IP address of the requester').label('TokenIP'),
|
|
285
|
+
|
|
286
|
+
id: tokenIdSchema
|
|
287
|
+
}).label('AccountTokensItem')
|
|
288
|
+
)
|
|
289
|
+
.label('AccountTokensEntries')
|
|
290
|
+
}).label('AccountsTokensResponse'),
|
|
291
|
+
failAction: 'log'
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = init;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Joi = require('joi');
|
|
4
|
+
const { webhooks: Webhooks } = require('../webhooks');
|
|
5
|
+
const { failAction } = require('../tools');
|
|
6
|
+
const { handleError } = require('./route-helpers');
|
|
7
|
+
const { settingsSchema } = require('../schemas');
|
|
8
|
+
|
|
9
|
+
async function init(args) {
|
|
10
|
+
const { server, CORS_CONFIG } = args;
|
|
11
|
+
|
|
12
|
+
server.route({
|
|
13
|
+
method: 'GET',
|
|
14
|
+
path: '/v1/webhookRoutes',
|
|
15
|
+
|
|
16
|
+
async handler(request) {
|
|
17
|
+
try {
|
|
18
|
+
return await Webhooks.list(request.query.page, request.query.pageSize);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
handleError(request, err);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
options: {
|
|
25
|
+
description: 'List webhook routes',
|
|
26
|
+
notes: 'List custom webhook routes',
|
|
27
|
+
tags: ['api', 'Webhooks'],
|
|
28
|
+
|
|
29
|
+
plugins: {},
|
|
30
|
+
|
|
31
|
+
auth: {
|
|
32
|
+
strategy: 'api-token',
|
|
33
|
+
mode: 'required'
|
|
34
|
+
},
|
|
35
|
+
cors: CORS_CONFIG,
|
|
36
|
+
|
|
37
|
+
validate: {
|
|
38
|
+
options: {
|
|
39
|
+
stripUnknown: false,
|
|
40
|
+
abortEarly: false,
|
|
41
|
+
convert: true
|
|
42
|
+
},
|
|
43
|
+
failAction,
|
|
44
|
+
|
|
45
|
+
query: Joi.object({
|
|
46
|
+
page: Joi.number()
|
|
47
|
+
.integer()
|
|
48
|
+
.min(0)
|
|
49
|
+
.max(1024 * 1024)
|
|
50
|
+
.default(0)
|
|
51
|
+
.example(0)
|
|
52
|
+
.description('Page number (zero indexed, so use 0 for first page)')
|
|
53
|
+
.label('PageNumber'),
|
|
54
|
+
pageSize: Joi.number().integer().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize')
|
|
55
|
+
}).label('WebhookRoutesListRequest')
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
response: {
|
|
59
|
+
schema: Joi.object({
|
|
60
|
+
total: Joi.number().integer().example(120).description('How many matching entries').label('TotalNumber'),
|
|
61
|
+
page: Joi.number().integer().example(0).description('Current page (0-based index)').label('PageNumber'),
|
|
62
|
+
pages: Joi.number().integer().example(24).description('Total page count').label('PagesNumber'),
|
|
63
|
+
|
|
64
|
+
webhooks: Joi.array()
|
|
65
|
+
.items(
|
|
66
|
+
Joi.object({
|
|
67
|
+
id: Joi.string().max(256).required().example('AAABgS-UcAYAAAABAA').description('Webhook ID'),
|
|
68
|
+
name: Joi.string().max(256).example('Send to Slack').description('Name of the route').label('WebhookRouteName').required(),
|
|
69
|
+
description: Joi.string()
|
|
70
|
+
.allow('')
|
|
71
|
+
.max(1024)
|
|
72
|
+
.example('Something about the route')
|
|
73
|
+
.description('Optional description of the webhook route')
|
|
74
|
+
.label('WebhookRouteDescription'),
|
|
75
|
+
created: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was created'),
|
|
76
|
+
updated: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was last updated'),
|
|
77
|
+
enabled: Joi.boolean().example(true).description('Is the route enabled').label('WebhookRouteEnabled'),
|
|
78
|
+
targetUrl: settingsSchema.webhooks,
|
|
79
|
+
tcount: Joi.number().integer().example(123).description('How many times this route has been applied')
|
|
80
|
+
}).label('WebhookRoutesListEntry')
|
|
81
|
+
)
|
|
82
|
+
.label('WebhookRoutesList')
|
|
83
|
+
}).label('WebhookRoutesListResponse'),
|
|
84
|
+
failAction: 'log'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
server.route({
|
|
90
|
+
method: 'GET',
|
|
91
|
+
path: '/v1/webhookRoutes/webhookRoute/{webhookRoute}',
|
|
92
|
+
|
|
93
|
+
async handler(request) {
|
|
94
|
+
try {
|
|
95
|
+
return await Webhooks.get(request.params.webhookRoute);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
handleError(request, err);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
options: {
|
|
102
|
+
description: 'Get webhook route information',
|
|
103
|
+
notes: 'Retrieve webhook route content and information',
|
|
104
|
+
tags: ['api', 'Webhooks'],
|
|
105
|
+
|
|
106
|
+
plugins: {},
|
|
107
|
+
|
|
108
|
+
auth: {
|
|
109
|
+
strategy: 'api-token',
|
|
110
|
+
mode: 'required'
|
|
111
|
+
},
|
|
112
|
+
cors: CORS_CONFIG,
|
|
113
|
+
|
|
114
|
+
validate: {
|
|
115
|
+
options: {
|
|
116
|
+
stripUnknown: false,
|
|
117
|
+
abortEarly: false,
|
|
118
|
+
convert: true
|
|
119
|
+
},
|
|
120
|
+
failAction,
|
|
121
|
+
params: Joi.object({
|
|
122
|
+
webhookRoute: Joi.string().max(256).required().example('example').description('Webhook ID')
|
|
123
|
+
}).label('GetWebhookRouteRequest')
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
response: {
|
|
127
|
+
schema: Joi.object({
|
|
128
|
+
id: Joi.string().max(256).required().example('AAABgS-UcAYAAAABAA').description('Webhook ID'),
|
|
129
|
+
name: Joi.string().max(256).example('Send to Slack').description('Name of the route').label('WebhookRouteName').required(),
|
|
130
|
+
description: Joi.string()
|
|
131
|
+
.allow('')
|
|
132
|
+
.max(1024)
|
|
133
|
+
.example('Something about the route')
|
|
134
|
+
.description('Optional description of the webhook route')
|
|
135
|
+
.label('WebhookRouteDescription'),
|
|
136
|
+
created: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was created'),
|
|
137
|
+
updated: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was last updated'),
|
|
138
|
+
enabled: Joi.boolean().example(true).description('Is the route enabled').label('WebhookRouteEnabled'),
|
|
139
|
+
targetUrl: settingsSchema.webhooks,
|
|
140
|
+
tcount: Joi.number().integer().example(123).description('How many times this route has been applied'),
|
|
141
|
+
content: Joi.object({
|
|
142
|
+
fn: Joi.string().example('return true;').description('Filter function'),
|
|
143
|
+
map: Joi.string().example('payload.ts = Date.now(); return payload;').description('Mapping function')
|
|
144
|
+
}).label('WebhookRouteContent')
|
|
145
|
+
}).label('WebhookRouteResponse'),
|
|
146
|
+
failAction: 'log'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = init;
|
|
@@ -2700,6 +2700,20 @@ class GmailClient extends BaseClient {
|
|
|
2700
2700
|
queryParts.push(search.gmailRaw);
|
|
2701
2701
|
}
|
|
2702
2702
|
|
|
2703
|
+
// Label filters - "has" matches messages with the label, "not" excludes them
|
|
2704
|
+
if (search.labels && typeof search.labels === 'object') {
|
|
2705
|
+
for (let label of [].concat(search.labels.has || [])) {
|
|
2706
|
+
if (label) {
|
|
2707
|
+
queryParts.push(`label:${this.formatSearchTerm(label)}`);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
for (let label of [].concat(search.labels.not || [])) {
|
|
2711
|
+
if (label) {
|
|
2712
|
+
queryParts.push(`-label:${this.formatSearchTerm(label)}`);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2703
2717
|
// body search
|
|
2704
2718
|
if (search.body && typeof search.body === 'string') {
|
|
2705
2719
|
queryParts.push(`${this.formatSearchTerm(search.body)}`);
|
|
@@ -23,7 +23,6 @@ const {
|
|
|
23
23
|
MESSAGE_DELETED_NOTIFY,
|
|
24
24
|
MESSAGE_UPDATED_NOTIFY,
|
|
25
25
|
MESSAGE_MISSING_NOTIFY,
|
|
26
|
-
MAILBOX_RESET_NOTIFY,
|
|
27
26
|
MAILBOX_NEW_NOTIFY,
|
|
28
27
|
EMAIL_BOUNCE_NOTIFY,
|
|
29
28
|
EMAIL_COMPLAINT_NOTIFY,
|
|
@@ -41,6 +40,7 @@ const {
|
|
|
41
40
|
canUseCondstorePartialSync,
|
|
42
41
|
canUseSimplePartialSync,
|
|
43
42
|
canSkipSync,
|
|
43
|
+
shouldSeedLostIndex,
|
|
44
44
|
FULL_SYNC_DELAY
|
|
45
45
|
} = require('./sync-operations');
|
|
46
46
|
|
|
@@ -154,6 +154,8 @@ class Mailbox {
|
|
|
154
154
|
let data = await this.connection.redis.hgetall(this.getMailboxKey());
|
|
155
155
|
data = data || {};
|
|
156
156
|
|
|
157
|
+
let hasStoredState = Object.keys(data).length > 0;
|
|
158
|
+
|
|
157
159
|
// Log diagnostic info if stored uidValidity is invalid or missing
|
|
158
160
|
if (!validUidValidity(data.uidValidity)) {
|
|
159
161
|
this.logger.warn({
|
|
@@ -162,12 +164,16 @@ class Mailbox {
|
|
|
162
164
|
redisKey: this.getMailboxKey(),
|
|
163
165
|
rawUidValidity: data.uidValidity,
|
|
164
166
|
rawUidValidityType: typeof data.uidValidity,
|
|
165
|
-
hasData:
|
|
167
|
+
hasData: hasStoredState,
|
|
166
168
|
storedKeys: Object.keys(data)
|
|
167
169
|
});
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
return {
|
|
173
|
+
// True when the mailbox hash held any fields at all. Redis eviction removes
|
|
174
|
+
// whole keys, so this distinguishes "state lost" from "individual fields
|
|
175
|
+
// never persisted" (e.g. uidNext on servers that omit UIDNEXT from SELECT)
|
|
176
|
+
hasStoredState,
|
|
171
177
|
path: data.path || this.path,
|
|
172
178
|
uidValidity: validUidValidity(data.uidValidity) ? BigInt(data.uidValidity) : false,
|
|
173
179
|
highestModseq: data.highestModseq && !isNaN(data.highestModseq) ? BigInt(data.highestModseq) : false,
|
|
@@ -1934,6 +1940,16 @@ class Mailbox {
|
|
|
1934
1940
|
return this.syncOps.partialSync(storedStatus);
|
|
1935
1941
|
}
|
|
1936
1942
|
|
|
1943
|
+
/**
|
|
1944
|
+
* Silently rebuilds the message index after lost or invalidated sync state
|
|
1945
|
+
* Delegates to SyncOperations
|
|
1946
|
+
* @param {Object} mailboxStatus - Current mailbox status from IMAP
|
|
1947
|
+
* @param {Object} [options] - Reseed options (reason, prevUidValidity)
|
|
1948
|
+
*/
|
|
1949
|
+
async seedMailboxIndex(mailboxStatus, options) {
|
|
1950
|
+
return this.syncOps.seedMailboxIndex(mailboxStatus, options);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1937
1953
|
/**
|
|
1938
1954
|
* Processes queued notification events after sync
|
|
1939
1955
|
* Fetches full message details and sends notifications
|
|
@@ -2060,6 +2076,15 @@ class Mailbox {
|
|
|
2060
2076
|
try {
|
|
2061
2077
|
let storedStatus = await this.getStoredStatus();
|
|
2062
2078
|
|
|
2079
|
+
// Lost-index recovery: the account has synced in a previous session but this folder
|
|
2080
|
+
// has no stored state at all (e.g. Redis evicted the whole hash) while the server
|
|
2081
|
+
// still has messages. Rebuild the baseline silently instead of replaying every
|
|
2082
|
+
// message as a new email.
|
|
2083
|
+
if (shouldSeedLostIndex(storedStatus, mailboxStatus, this.previouslyConnected)) {
|
|
2084
|
+
await this.seedMailboxIndex(mailboxStatus, { reason: 'syncStateLost' });
|
|
2085
|
+
return false;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2063
2088
|
// Store initial UID on first sync
|
|
2064
2089
|
if (storedStatus.uidNext === false && typeof mailboxStatus.uidNext === 'number') {
|
|
2065
2090
|
// update first UID
|
|
@@ -2088,19 +2113,17 @@ class Mailbox {
|
|
|
2088
2113
|
});
|
|
2089
2114
|
|
|
2090
2115
|
this.logger.debug({ msg: 'Mailbox reset', path: this.listingEntry.path });
|
|
2091
|
-
await this.connection.notify(this, MAILBOX_RESET_NOTIFY, {
|
|
2092
|
-
path: this.listingEntry.path,
|
|
2093
|
-
name: this.listingEntry.name,
|
|
2094
|
-
specialUse: this.listingEntry.specialUse || false,
|
|
2095
|
-
uidValidity: validUidValidity(mailboxStatus.uidValidity) ? mailboxStatus.uidValidity.toString() : false,
|
|
2096
|
-
prevUidValidity: validUidValidity(storedStatus.uidValidity) ? storedStatus.uidValidity.toString() : false
|
|
2097
|
-
});
|
|
2098
2116
|
|
|
2099
2117
|
// do not advertise messages as new
|
|
2100
2118
|
this.listingEntry.isNew = true;
|
|
2101
2119
|
|
|
2102
|
-
//
|
|
2103
|
-
|
|
2120
|
+
// Rebuild the index silently from the recreated mailbox and emit a single
|
|
2121
|
+
// mailboxReset (with prev/current UIDVALIDITY) instead of replaying every message.
|
|
2122
|
+
await this.seedMailboxIndex(mailboxStatus, {
|
|
2123
|
+
reason: 'uidValidityChange',
|
|
2124
|
+
prevUidValidity: validUidValidity(storedStatus.uidValidity) ? storedStatus.uidValidity.toString() : false
|
|
2125
|
+
});
|
|
2126
|
+
return false;
|
|
2104
2127
|
}
|
|
2105
2128
|
|
|
2106
2129
|
// Determine sync strategy using helper functions
|
|
@@ -210,24 +210,32 @@ class Subconnection extends EventEmitter {
|
|
|
210
210
|
let response = await imapClient.connect();
|
|
211
211
|
|
|
212
212
|
// Process untagged EXISTS responses
|
|
213
|
-
imapClient.on('exists',
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
213
|
+
imapClient.on('exists', event => {
|
|
214
|
+
try {
|
|
215
|
+
if (!event || !event.path) {
|
|
216
|
+
return; //?
|
|
217
|
+
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
this.logger.info({ msg: 'Exists notification', account: this.account, event });
|
|
219
220
|
|
|
220
|
-
|
|
221
|
+
this.requestSync();
|
|
222
|
+
} catch (err) {
|
|
223
|
+
this.logger.error({ msg: 'Exists notification handling failed', account: this.account, err });
|
|
224
|
+
}
|
|
221
225
|
});
|
|
222
226
|
|
|
223
|
-
imapClient.on('flags',
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
imapClient.on('flags', event => {
|
|
228
|
+
try {
|
|
229
|
+
if (!event || !event.path) {
|
|
230
|
+
return; //?
|
|
231
|
+
}
|
|
227
232
|
|
|
228
|
-
|
|
233
|
+
this.logger.info({ msg: 'Flags notification', account: this.account, event });
|
|
229
234
|
|
|
230
|
-
|
|
235
|
+
this.requestSync();
|
|
236
|
+
} catch (err) {
|
|
237
|
+
this.logger.error({ msg: 'Flags notification handling failed', account: this.account, err });
|
|
238
|
+
}
|
|
231
239
|
});
|
|
232
240
|
|
|
233
241
|
return response;
|