emailengine-app 2.68.1 → 2.70.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.
Files changed (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
@@ -16,6 +16,7 @@ jobs:
16
16
  deploy:
17
17
  name: Deploy Demo App
18
18
  runs-on: ubuntu-24.04
19
+ timeout-minutes: 15
19
20
 
20
21
  steps:
21
22
  - name: Checkout
@@ -40,7 +41,7 @@ jobs:
40
41
  id: deploy
41
42
  run: |
42
43
  echo $GITHUB_SHA > commit.txt
43
- ./update-info.sh
44
+ EE_COMMIT_HASH=$GITHUB_SHA ./update-info.sh
44
45
  npm install --omit=dev
45
46
  tar czf /tmp/${SERVICE_NAME}.tar.gz --exclude .git .
46
47
  scp -o "UserKnownHostsFile=/dev/null" -o "StrictHostKeyChecking=no" /tmp/${SERVICE_NAME}.tar.gz deploy@${TARGET_HOST_02}:
@@ -54,7 +55,7 @@ jobs:
54
55
  id: deploy-demo
55
56
  run: |
56
57
  echo $GITHUB_SHA > commit.txt
57
- ./update-info.sh
58
+ EE_COMMIT_HASH=$GITHUB_SHA ./update-info.sh
58
59
  npm install --omit=dev
59
60
  tar czf /tmp/${SERVICE_NAME}.tar.gz --exclude .git .
60
61
  scp -o "UserKnownHostsFile=/dev/null" -o "StrictHostKeyChecking=no" /tmp/${SERVICE_NAME}.tar.gz deploy@${TARGET_HOST_02}:
@@ -63,6 +64,7 @@ jobs:
63
64
  docker:
64
65
  name: Build Docker Image
65
66
  runs-on: ubuntu-24.04
67
+ timeout-minutes: 30
66
68
  permissions:
67
69
  contents: read
68
70
  packages: write
@@ -102,8 +104,11 @@ jobs:
102
104
  images: |
103
105
  ${{ github.repository }}
104
106
  ghcr.io/${{ github.repository }}
107
+ # :latest is owned by the release workflow (release.yaml) and
108
+ # always points at the newest release; per-push dev builds from
109
+ # master are published as :edge instead
105
110
  tags: |
106
- type=raw,value=latest,enable=true
111
+ type=raw,value=edge,enable=true
107
112
 
108
113
  - name: Build and push
109
114
  uses: docker/build-push-action@v7
@@ -63,6 +63,10 @@ jobs:
63
63
  runs-on: ubuntu-latest
64
64
  needs: release_please
65
65
  if: ${{needs.release_please.outputs.release_created}}
66
+ # Multi-arch builds run the arm64 leg under QEMU emulation, which can
67
+ # hang indefinitely on a misbehaving step. A normal build finishes in
68
+ # ~4 minutes, so fail fast instead of burning the 6-hour default limit.
69
+ timeout-minutes: 30
66
70
  steps:
67
71
  - run: echo version v${{needs.release_please.outputs.major}}.${{needs.release_please.outputs.minor}}.${{needs.release_please.outputs.patch}}
68
72
 
@@ -99,9 +103,11 @@ jobs:
99
103
  platforms: ${{ steps.buildx.outputs.platforms }}
100
104
  push: true
101
105
  tags: |
106
+ ${{ github.repository }}:latest
102
107
  ${{ github.repository }}:v${{needs.release_please.outputs.major}}.${{needs.release_please.outputs.minor}}.${{needs.release_please.outputs.patch}}
103
108
  ${{ github.repository }}:v${{needs.release_please.outputs.major}}.${{needs.release_please.outputs.minor}}
104
109
  ${{ github.repository }}:v${{needs.release_please.outputs.major}}
110
+ ghcr.io/${{ github.repository }}:latest
105
111
  ghcr.io/${{ github.repository }}:v${{needs.release_please.outputs.major}}.${{needs.release_please.outputs.minor}}.${{needs.release_please.outputs.patch}}
106
112
  ghcr.io/${{ github.repository }}:v${{needs.release_please.outputs.major}}.${{needs.release_please.outputs.minor}}
107
113
  ghcr.io/${{ github.repository }}:v${{needs.release_please.outputs.major}}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.70.0](https://github.com/postalsys/emailengine/compare/v2.69.0...v2.70.0) (2026-06-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * allow enabling Sentry error reporting from the admin UI ([a4076a4](https://github.com/postalsys/emailengine/commit/a4076a4fa6658ccc998f9bfe35dddf015cd12b09))
9
+ * replace Bugsnag with self-hosted Sentry for error tracking ([62de831](https://github.com/postalsys/emailengine/commit/62de831a2911da9b649fe693547ba09d70e2e481))
10
+ * tag Sentry error reports with instance id and license key ([2e9683f](https://github.com/postalsys/emailengine/commit/2e9683f27d54ff8fa7ba3f6f4453866fd135f173))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * align API response schemas with actual responses and fix uncovered API bugs ([abacbf6](https://github.com/postalsys/emailengine/commit/abacbf66f048a77d00d180a831a97594380d8ed9))
16
+ * align remaining API response schemas with actual responses ([06136ab](https://github.com/postalsys/emailengine/commit/06136abd20a97c45b25aac7f93653387eb55928a))
17
+ * assign unassigned accounts after license activation ([5677977](https://github.com/postalsys/emailengine/commit/56779778eba27f2d1604ff06f17e5736a5ddee61))
18
+ * do not crash at startup when a feature flag env variable is set to a falsy value ([98ad979](https://github.com/postalsys/emailengine/commit/98ad97988e752fad9e2ae11eff63f35ffcf84976))
19
+ * report correct nextAttempt time for queued messages ([d5ebdfb](https://github.com/postalsys/emailengine/commit/d5ebdfb029b3ed331a8239ace7426f4f0704ae2e))
20
+ * resolve code review findings in Gmail bulk ops, exports, webhooks, and schemas ([f0ddb46](https://github.com/postalsys/emailengine/commit/f0ddb4631fb924ddb93e0a99d0e20bc9f0cc8ee2))
21
+
22
+ ## [2.69.0](https://github.com/postalsys/emailengine/compare/v2.68.1...v2.69.0) (2026-06-09)
23
+
24
+
25
+ ### Features
26
+
27
+ * add label/category filtering to message search ([c171c4e](https://github.com/postalsys/emailengine/commit/c171c4e1efe582f7e532b69108110a1c48aa3ede))
28
+ * detect unsafe Redis eviction config in dashboard warnings ([712d5d6](https://github.com/postalsys/emailengine/commit/712d5d69b99cc45c6f3e123a084ef054236e0110))
29
+ * rebuild lost IMAP sync state without replaying messageNew ([9fe391e](https://github.com/postalsys/emailengine/commit/9fe391e9d6350485aded780709305d698a4c65fb))
30
+ * surface token hash for identification in API, UI, and logs ([8e2a5a9](https://github.com/postalsys/emailengine/commit/8e2a5a9f2bb5194e92575c34c0006a8c4e21888d))
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * avoid 500 on GET /admin/totp without a partial-auth session ([b6f2394](https://github.com/postalsys/emailengine/commit/b6f2394182379d94a05f95c84f5ce04ae6f87a05))
36
+ * bound inbound line length in the IMAP proxy parser ([9fb8a5e](https://github.com/postalsys/emailengine/commit/9fb8a5e301564dee46b48ca32a5b84e4832d96e8))
37
+ * close leaks and a worker crash found in the connection hardening review ([e97950e](https://github.com/postalsys/emailengine/commit/e97950e7e907ac5b5549302b8865c5d2bfb0d6e2))
38
+ * count undecodable export queue entries as skipped ([72e2498](https://github.com/postalsys/emailengine/commit/72e2498390f17ad24d5f7b1577540674d7e1fe09))
39
+ * distinguish SO_REUSEPORT fallback cause in logs and admin UI ([99ad9f3](https://github.com/postalsys/emailengine/commit/99ad9f3359eab5bafc4726535584874d4823f7bd))
40
+ * drain webhook response body on every delivery path ([2cc3e7a](https://github.com/postalsys/emailengine/commit/2cc3e7aef114b5b913dea5c363278e1a883d5a48))
41
+ * extract gettext strings from the decomposed ui-routes modules ([227e60f](https://github.com/postalsys/emailengine/commit/227e60ff4f354c5d0f55534877c8ab6fbdd92c7d))
42
+ * guard cross-thread download streams against early producer errors ([191ec62](https://github.com/postalsys/emailengine/commit/191ec6204887c3abe78da7a9944e3165b19c0b87))
43
+ * guard IMAP notification handlers against malformed events ([0248953](https://github.com/postalsys/emailengine/commit/0248953a5dcccba5d46704bf7113a70fb5ac3150))
44
+ * guard webhook notifications in BullMQ failure handlers ([e90048e](https://github.com/postalsys/emailengine/commit/e90048e7b7bd92916219602f92305342234df4a3))
45
+ * harden IMAP proxy STARTTLS against command injection ([3661ddd](https://github.com/postalsys/emailengine/commit/3661dddbf216b29ec8f074d3f6422dd4db5d9e7b))
46
+ * harden inter-thread IPC (worker-death call rejection, webhook guards, stream cleanup) ([0111a8e](https://github.com/postalsys/emailengine/commit/0111a8e2bf139c3412cc37e5e933fd5316cee8fc))
47
+ * harden message export against data loss and corruption ([540d867](https://github.com/postalsys/emailengine/commit/540d8678ec25f62e8123fc78fa929f4e4713b5d4))
48
+ * harden multiple-API-worker startup and centralize proxy-agent reload ([7f2db9e](https://github.com/postalsys/emailengine/commit/7f2db9e557d960aa1b7523b7d8678598683c2f29))
49
+ * list Outlook folders with slashes in the name ([dd35e7b](https://github.com/postalsys/emailengine/commit/dd35e7b262a47f9395811d75c4bcc1d9ab4c6ec0))
50
+ * only reseed lost IMAP sync state when no stored state exists ([78e0141](https://github.com/postalsys/emailengine/commit/78e0141e9f3478d2a2a1206c805258c6807a45d8))
51
+ * prevent IMAP worker crash on download stream errors ([d9d5396](https://github.com/postalsys/emailengine/commit/d9d539632cc245a8e2a14585fc7918186bee00b6))
52
+ * prevent post-BYE command dispatch in IMAP proxy teardown ([d51e92d](https://github.com/postalsys/emailengine/commit/d51e92d298e9373a6dc5c2eb0919885e161b45c7))
53
+ * re-arm a generous idle timeout for proxied IMAP connections ([627c6d4](https://github.com/postalsys/emailengine/commit/627c6d472e50f6fc3e958e4774226557a526018d))
54
+ * reject in-flight worker calls when a worker thread terminates ([08e7b01](https://github.com/postalsys/emailengine/commit/08e7b0128a4b8e9f9e5a96fefe49110bd032bd2c))
55
+ * release cross-thread download streams on abort and error ([6514249](https://github.com/postalsys/emailengine/commit/65142499d095bbcb5f0bb49755a2e24f6e52fa62))
56
+ * release MessagePort streams when message/attachment downloads fail ([787f4f5](https://github.com/postalsys/emailengine/commit/787f4f5ef6364ec1389c15269c8f7f8eaaa2142a))
57
+ * return 422 for unsupported label search and surface Outlook categories ([d981359](https://github.com/postalsys/emailengine/commit/d981359b78b61d2c8a03e4e35086a4c9674ce53a))
58
+ * return the append destination folder from uploadMessage ([96aed6b](https://github.com/postalsys/emailengine/commit/96aed6b1144e1d3ee9da79b3a1fb03606245f83c))
59
+ * send correctly typed SMTP and IMAP proxy state change notifications ([3fbd27f](https://github.com/postalsys/emailengine/commit/3fbd27fc08bb74f9738a3fc95d4177fa4978c263))
60
+ * tear down subconnections when deleting an IMAP account ([bd81a33](https://github.com/postalsys/emailengine/commit/bd81a33caf9400cbee2781a9180f1a901c1d6e48))
61
+
3
62
  ## [2.68.1](https://github.com/postalsys/emailengine/compare/v2.68.0...v2.68.1) (2026-06-01)
4
63
 
5
64
 
package/Gruntfile.js CHANGED
@@ -15,7 +15,9 @@ module.exports = function (grunt) {
15
15
 
16
16
  shell: {
17
17
  eslint: {
18
- command: 'npx eslint lib/**/*.js workers/**/*.js server.js Gruntfile.js',
18
+ // Globs are quoted so eslint expands them itself - unquoted, sh (no globstar)
19
+ // expands lib/**/*.js to depth-2 files only and skips most of lib/
20
+ command: "npx eslint 'lib/**/*.js' 'workers/**/*.js' server.js Gruntfile.js",
19
21
  options: {
20
22
  async: false
21
23
  }
@@ -1,6 +1,8 @@
1
1
 
2
2
  [workers]
3
3
  imap = 4
4
+ # Number of API/HTTP workers. Values >1 require SO_REUSEPORT (Linux); on other platforms it falls back to 1.
5
+ api = 1
4
6
  webhooks = 1
5
7
  "imapProxy" = 1
6
8
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2026-05-29T14:45:42.000000",
2
+ "creationTime": "2026-06-10T14:45:48.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
@@ -310,6 +310,9 @@
310
310
  {
311
311
  "ipv6Prefix": "2001:4860:4801:207e::/64"
312
312
  },
313
+ {
314
+ "ipv6Prefix": "2001:4860:4801:207f::/64"
315
+ },
313
316
  {
314
317
  "ipv6Prefix": "2001:4860:4801:2080::/64"
315
318
  },
@@ -796,6 +799,9 @@
796
799
  {
797
800
  "ipv4Prefix": "74.125.219.192/27"
798
801
  },
802
+ {
803
+ "ipv4Prefix": "74.125.219.224/27"
804
+ },
799
805
  {
800
806
  "ipv4Prefix": "74.125.219.32/27"
801
807
  },
package/getswagger.sh CHANGED
@@ -1,8 +1,44 @@
1
1
  #!/bin/bash
2
2
 
3
+ set -e
4
+
3
5
  export EENGINE_PORT=5678
4
6
 
5
- npm start > /dev/null 2>&1 &
6
- sleep 5
7
- curl -s "http://127.0.0.1:${EENGINE_PORT}/swagger.json" > swagger.json
8
- pkill emailengine
7
+ # refuse to run if something is already listening on the port, otherwise the
8
+ # polling loop below would silently fetch swagger.json from a stale instance
9
+ if (exec 3<>"/dev/tcp/127.0.0.1/${EENGINE_PORT}") 2>/dev/null; then
10
+ echo "Port ${EENGINE_PORT} is already in use" >&2
11
+ exit 1
12
+ fi
13
+
14
+ node server.js > /dev/null 2>&1 &
15
+ SERVER_PID=$!
16
+
17
+ cleanup() {
18
+ kill "$SERVER_PID" 2>/dev/null || true
19
+ wait "$SERVER_PID" 2>/dev/null || true
20
+ }
21
+ trap cleanup EXIT
22
+
23
+ # poll until the API is up instead of relying on a fixed sleep
24
+ rm -f swagger.json.tmp
25
+ for i in $(seq 1 60); do
26
+ if curl -fs --max-time 5 "http://127.0.0.1:${EENGINE_PORT}/swagger.json" -o swagger.json.tmp; then
27
+ break
28
+ fi
29
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
30
+ echo "Server exited before swagger.json could be fetched" >&2
31
+ exit 1
32
+ fi
33
+ sleep 1
34
+ done
35
+
36
+ if [ ! -s swagger.json.tmp ]; then
37
+ echo "Timed out waiting for swagger.json" >&2
38
+ exit 1
39
+ fi
40
+
41
+ # only replace swagger.json if the download parses as JSON
42
+ node -e 'JSON.parse(require("fs").readFileSync("swagger.json.tmp", "utf-8"))'
43
+
44
+ mv swagger.json.tmp swagger.json
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ // Extracts translatable strings from JS sources into translations/messages.pot.
4
+ // Run via `npm run gettext` after xgettext-template has generated the POT from the
5
+ // Handlebars views - this script joins the strings found in JS files into that file.
6
+ //
7
+ // Replaces jsxgettext, which bundled acorn 5 and crashed on post-ES2018 syntax
8
+ // (optional chaining, nullish coalescing, etc.). Files are discovered dynamically,
9
+ // so new modules with gettext()/ngettext() calls are picked up automatically.
10
+
11
+ const { parse } = require('acorn');
12
+ const { full: walkFull } = require('acorn-walk');
13
+ const { po } = require('gettext-parser');
14
+ const fs = require('fs');
15
+ const Path = require('path');
16
+
17
+ const ROOT_DIR = __dirname;
18
+ const POT_PATH = Path.join(ROOT_DIR, 'translations', 'messages.pot');
19
+
20
+ // All server-side code that may contain gettext()/ngettext() calls
21
+ const SCAN_DIRS = ['bin', 'lib', 'workers'];
22
+ const SCAN_FILES = ['server.js'];
23
+
24
+ function listJsFiles(dir) {
25
+ return fs
26
+ .readdirSync(dir, { recursive: true })
27
+ .filter(entryPath => entryPath.endsWith('.js'))
28
+ .map(entryPath => Path.join(dir, entryPath));
29
+ }
30
+
31
+ // The full sorted scan set (sorted to keep POT reference output deterministic)
32
+ function listScanFiles() {
33
+ let files = SCAN_FILES.map(file => Path.join(ROOT_DIR, file));
34
+ for (let dir of SCAN_DIRS) {
35
+ files = files.concat(listJsFiles(Path.join(ROOT_DIR, dir)));
36
+ }
37
+ return files.sort();
38
+ }
39
+
40
+ function parseFile(filePath) {
41
+ const source = fs.readFileSync(filePath, 'utf-8');
42
+ try {
43
+ return parse(source, { ecmaVersion: 'latest', sourceType: 'script', locations: true, allowHashBang: true });
44
+ } catch (err) {
45
+ err.message = `Failed to parse ${filePath}: ${err.message}`;
46
+ throw err;
47
+ }
48
+ }
49
+
50
+ // Resolves a call argument into a string value. Handles string literals and
51
+ // concatenation of string literals ('foo' + 'bar'). Returns false for anything
52
+ // dynamic (identifiers, template literals with expressions, etc.) - such calls
53
+ // forward runtime values and do not define new translatable strings.
54
+ function resolveString(node) {
55
+ switch (node.type) {
56
+ case 'Literal':
57
+ return typeof node.value === 'string' ? node.value : false;
58
+ case 'TemplateLiteral':
59
+ return node.expressions.length === 0 ? node.quasis[0].value.cooked : false;
60
+ case 'BinaryExpression': {
61
+ if (node.operator !== '+') {
62
+ return false;
63
+ }
64
+ let left = resolveString(node.left);
65
+ let right = resolveString(node.right);
66
+ return left !== false && right !== false ? left + right : false;
67
+ }
68
+ default:
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function calleeName(callee) {
74
+ if (callee.type === 'Identifier') {
75
+ return callee.name;
76
+ }
77
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
78
+ return callee.property.name;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function extractFromFile(filePath) {
84
+ const ast = parseFile(filePath);
85
+
86
+ let entries = [];
87
+ let reference = node => `${Path.relative(ROOT_DIR, filePath)}:${node.loc.start.line}`;
88
+
89
+ walkFull(ast, node => {
90
+ if (node.type !== 'CallExpression') {
91
+ return;
92
+ }
93
+
94
+ let name = calleeName(node.callee);
95
+
96
+ // An empty msgid is skipped (like xgettext does): translations[''] is the POT header
97
+ // entry in gettext-parser, so an empty key would corrupt the header block
98
+ if (name === 'gettext' && node.arguments.length >= 1) {
99
+ let msgid = resolveString(node.arguments[0]);
100
+ if (msgid !== false && msgid !== '') {
101
+ entries.push({ msgid, reference: reference(node) });
102
+ }
103
+ }
104
+
105
+ if (name === 'ngettext' && node.arguments.length >= 2) {
106
+ let msgid = resolveString(node.arguments[0]);
107
+ let msgidPlural = resolveString(node.arguments[1]);
108
+ if (msgid !== false && msgid !== '' && msgidPlural !== false) {
109
+ entries.push({ msgid, msgidPlural, reference: reference(node) });
110
+ }
111
+ }
112
+ });
113
+
114
+ return entries;
115
+ }
116
+
117
+ function main() {
118
+ let files = listScanFiles();
119
+
120
+ let extracted = [];
121
+ for (let filePath of files) {
122
+ extracted = extracted.concat(extractFromFile(filePath));
123
+ }
124
+
125
+ let pot = po.parse(fs.readFileSync(POT_PATH));
126
+ let translations = (pot.translations[''] = pot.translations[''] || {});
127
+
128
+ // xgettext-template does not stamp a creation date, so set it here (jsxgettext used to)
129
+ pot.headers = pot.headers || {};
130
+ pot.headers['POT-Creation-Date'] = new Date()
131
+ .toISOString()
132
+ .replace(/T/, ' ')
133
+ .replace(/:\d+\.\d+Z$/, '+0000');
134
+
135
+ for (let { msgid, msgidPlural, reference } of extracted) {
136
+ let entry = translations[msgid];
137
+ if (!entry) {
138
+ entry = translations[msgid] = { msgid, msgstr: [''] };
139
+ }
140
+
141
+ if (msgidPlural && !entry.msgid_plural) {
142
+ entry.msgid_plural = msgidPlural;
143
+ entry.msgstr = ['', ''];
144
+ }
145
+
146
+ entry.comments = entry.comments || {};
147
+ let references = entry.comments.reference ? entry.comments.reference.split('\n') : [];
148
+ if (!references.includes(reference)) {
149
+ references.push(reference);
150
+ }
151
+ entry.comments.reference = references.join('\n');
152
+ }
153
+
154
+ fs.writeFileSync(POT_PATH, po.compile(pot));
155
+ console.log(`Extracted ${extracted.length} gettext strings from ${files.length} JS files into ${Path.relative(ROOT_DIR, POT_PATH)}`);
156
+ }
157
+
158
+ if (require.main === module) {
159
+ main();
160
+ }
161
+
162
+ // Exported for the gettext coverage test, which walks the same scan set with the same helpers
163
+ module.exports = { listScanFiles, parseFile, calleeName, extractFromFile };