ultimate-jekyll-manager 0.0.281 → 0.0.283

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/CHANGELOG.md CHANGED
@@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
17
17
  ---
18
18
  ## [Unreleased]
19
19
  ### Added
20
+ - Email preferences page (`/portal/account/email-preferences`) for unsubscribe/resubscribe from marketing emails
21
+ - Email masking on preferences page to prevent forwarded-email abuse (e.g., `ia***b@gm***.com`)
22
+ - HMAC signature verification for unsubscribe links to prevent forged requests
20
23
  - Checkout page supports daily, weekly, monthly, and annually billing frequencies with selective UI visibility via wm-bindings
21
24
  - Default billing frequency auto-selects the longest available term (annually > monthly > weekly > daily), with URL param override
22
25
  - Auth state settles before any authorized fetches fire on checkout, preventing race conditions
@@ -26,6 +26,10 @@ export default function (Manager, options) {
26
26
  unauthenticated
27
27
  });
28
28
 
29
+ // LEGACY: Handle desktop app auth params (e.g. ?destination=appscheme://page&source=app)
30
+ // TODO: Remove this call AND the _legacyTranslateAppAuth function when legacy desktop app support is no longer needed
31
+ _legacyTranslateAppAuth();
32
+
29
33
  // Track if we just signed out to avoid redirect loops
30
34
  let justSignedOut = false;
31
35
 
@@ -243,3 +247,36 @@ async function sendUserSignupMetadata(user, webManager) {
243
247
  // Don't throw - we don't want to block the signup flow
244
248
  }
245
249
  }
250
+
251
+ // LEGACY: Translate desktop app auth params to UJM format
252
+ // Legacy apps send: ?destination=appscheme://page&source=app&signout=true&cb=timestamp
253
+ // UJM expects: ?authReturnUrl=...&authSignout=true
254
+ // TODO: Remove this function AND its call above when legacy desktop app support is no longer needed
255
+ function _legacyTranslateAppAuth() {
256
+ const url = new URL(window.location.href);
257
+ const destination = url.searchParams.get('destination');
258
+ const source = url.searchParams.get('source');
259
+
260
+ if (source !== 'app' || !destination) {
261
+ return;
262
+ }
263
+
264
+ // Chain through /token page to generate a custom token before redirecting to the app
265
+ const tokenPageUrl = new URL('/token', window.location.origin);
266
+ tokenPageUrl.searchParams.set('authReturnUrl', destination);
267
+ url.searchParams.set('authReturnUrl', tokenPageUrl.toString());
268
+
269
+ // Translate signout param
270
+ if (url.searchParams.get('signout') === 'true') {
271
+ url.searchParams.set('authSignout', 'true');
272
+ }
273
+
274
+ // Clean up legacy params and update URL
275
+ url.searchParams.delete('destination');
276
+ url.searchParams.delete('source');
277
+ url.searchParams.delete('signout');
278
+ url.searchParams.delete('cb');
279
+ window.history.replaceState({}, '', url.toString());
280
+
281
+ console.log('[Auth] Translated legacy app params:', url.toString());
282
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Email Preferences Page JavaScript
3
+ */
4
+
5
+ // Libraries
6
+ import { FormManager } from '__main_assets__/js/libs/form-manager.js';
7
+ import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
8
+ import fetch from 'wonderful-fetch';
9
+
10
+ let webManager = null;
11
+
12
+ // Module
13
+ export default (Manager) => {
14
+ return new Promise(async function (resolve) {
15
+ // Shortcuts
16
+ webManager = Manager.webManager;
17
+
18
+ // Initialize when DOM is ready
19
+ await webManager.dom().ready();
20
+
21
+ setupForm();
22
+
23
+ // Resolve after initialization
24
+ return resolve();
25
+ });
26
+ };
27
+
28
+ // Decode a base64-encoded URL parameter
29
+ function decodeParam(encoded) {
30
+ if (!encoded) {
31
+ return '';
32
+ }
33
+
34
+ try {
35
+ return atob(decodeURIComponent(encoded));
36
+ } catch (e) {
37
+ return '';
38
+ }
39
+ }
40
+
41
+ // Mask an email address: show first 2 and last char of local part, first 2 chars of domain
42
+ // e.g. "ian.wiedenman+test-unsub@gmail.com" → "ia***************b@gm***.com"
43
+ function maskEmail(email) {
44
+ const [local, domain] = email.split('@');
45
+
46
+ if (!local || !domain) {
47
+ return '***@***.***';
48
+ }
49
+
50
+ const domainParts = domain.split('.');
51
+ const domainName = domainParts.slice(0, -1).join('.');
52
+ const tld = domainParts.slice(-1)[0];
53
+
54
+ const maskedLocal = local.length <= 3
55
+ ? local[0] + '*'.repeat(local.length - 1)
56
+ : local.slice(0, 2) + '*'.repeat(local.length - 3) + local.slice(-1);
57
+
58
+ const maskedDomain = domainName.length <= 2
59
+ ? domainName
60
+ : domainName.slice(0, 2) + '*'.repeat(domainName.length - 2);
61
+
62
+ return `${maskedLocal}@${maskedDomain}.${tld}`;
63
+ }
64
+
65
+ // Setup form handling
66
+ function setupForm() {
67
+ const url = new URL(window.location.href);
68
+
69
+ // Parse and decode URL parameters
70
+ const email = decodeParam(url.searchParams.get('email'));
71
+ const asmId = decodeParam(url.searchParams.get('asmId'));
72
+ const templateId = decodeParam(url.searchParams.get('templateId'));
73
+ const sig = url.searchParams.get('sig') || '';
74
+
75
+ // DOM elements
76
+ const $description = document.getElementById('email-preferences-description');
77
+ const $error = document.getElementById('email-preferences-error');
78
+ const $errorMessage = document.getElementById('email-preferences-error-message');
79
+ const $content = document.getElementById('email-preferences-content');
80
+ const $address = document.getElementById('email-preferences-address');
81
+ const $submit = document.getElementById('email-preferences-submit');
82
+ const $infoUnsubscribe = document.getElementById('email-preferences-info-unsubscribe');
83
+ const $infoResubscribe = document.getElementById('email-preferences-info-resubscribe');
84
+ const $actionButtons = document.querySelectorAll('[data-action]');
85
+
86
+ // Validate required parameters
87
+ if (!email || !asmId || !sig) {
88
+ $description.textContent = 'Something went wrong.';
89
+ $errorMessage.textContent = 'This link is invalid or expired. Please try clicking the link in your email again.';
90
+ $error.hidden = false;
91
+ return;
92
+ }
93
+
94
+ // Populate UI
95
+ $description.textContent = 'Confirm your email address to update your email preferences.';
96
+ $address.textContent = maskEmail(email);
97
+ $content.hidden = false;
98
+
99
+ // Track current action
100
+ let currentAction = 'unsubscribe';
101
+
102
+ // Action toggle buttons
103
+ $actionButtons.forEach($btn => {
104
+ $btn.addEventListener('click', () => {
105
+ currentAction = $btn.dataset.action;
106
+
107
+ // Update active state
108
+ $actionButtons.forEach($b => $b.classList.remove('active'));
109
+ $btn.classList.add('active');
110
+
111
+ // Toggle info messages
112
+ $infoUnsubscribe.hidden = currentAction !== 'unsubscribe';
113
+ $infoResubscribe.hidden = currentAction !== 'resubscribe';
114
+
115
+ // Update submit button
116
+ if (currentAction === 'unsubscribe') {
117
+ $submit.className = 'btn btn-danger w-100 mb-4';
118
+ $submit.querySelector('.button-text').innerHTML = `${getPrerenderedIcon('bell-slash', 'me-2')}Unsubscribe`;
119
+ } else {
120
+ $submit.className = 'btn btn-success w-100 mb-4';
121
+ $submit.querySelector('.button-text').innerHTML = `${getPrerenderedIcon('bell', 'me-2')}Resubscribe`;
122
+ }
123
+ });
124
+ });
125
+
126
+ // Initialize FormManager
127
+ const formManager = new FormManager('#email-preferences-form', {
128
+ autoReady: false,
129
+ allowResubmit: false,
130
+ });
131
+
132
+ // Custom validation: confirm email matches
133
+ formManager.on('validation', ({ data, setError }) => {
134
+ if (data.email_confirm.toLowerCase().trim() !== email.toLowerCase().trim()) {
135
+ setError('email_confirm', 'Email does not match. Please enter the email address this was sent to.');
136
+ }
137
+ });
138
+
139
+ // Submit handler
140
+ formManager.on('submit', async ({ data }) => {
141
+ const action = currentAction;
142
+
143
+ trackEmailPreference(action);
144
+
145
+ try {
146
+ await fetch(`${webManager.getApiUrl()}/backend-manager/marketing/email-preferences`, {
147
+ method: 'POST',
148
+ response: 'json',
149
+ body: {
150
+ email: email,
151
+ asmId: asmId,
152
+ action: action,
153
+ sig: sig,
154
+ },
155
+ timeout: 30000,
156
+ });
157
+
158
+ if (action === 'unsubscribe') {
159
+ formManager.showSuccess('You have been successfully unsubscribed. You will no longer receive these emails.');
160
+ } else {
161
+ formManager.showSuccess('You have been successfully resubscribed. You will start receiving these emails again.');
162
+ }
163
+ } catch (error) {
164
+ webManager.sentry().captureException(new Error('Email preferences error', { cause: error }));
165
+ throw new Error('An error occurred while processing your request. Please try again.');
166
+ }
167
+ });
168
+
169
+ // Ready
170
+ formManager.ready();
171
+ }
172
+
173
+ // Tracking
174
+ function trackEmailPreference(action) {
175
+ gtag('event', `email_${action}`, {
176
+ content_type: 'email_preferences',
177
+ });
178
+ fbq('trackCustom', action === 'unsubscribe' ? 'EmailUnsubscribe' : 'EmailResubscribe', {
179
+ content_name: 'Email Preferences',
180
+ });
181
+ ttq.track('ViewContent', {
182
+ content_id: `email-${action}`,
183
+ content_type: 'product',
184
+ });
185
+ }
@@ -47,11 +47,24 @@ export default function (Manager) {
47
47
 
48
48
  // Handle redirect or URL update
49
49
  if (authReturnUrl) {
50
- // Redirect to return URL with token (for electron/deep links)
50
+ // Redirect to return URL with token
51
51
  updateStatus('Redirecting...');
52
52
  const returnUrl = new URL(authReturnUrl);
53
53
  returnUrl.searchParams.set('authToken', token);
54
- window.location.href = returnUrl.toString();
54
+
55
+ // LEGACY: Reformat token for desktop app deep links
56
+ // TODO: Remove this block when legacy desktop app support is no longer needed
57
+ _legacyTranslateTokenRedirect(returnUrl, token);
58
+
59
+ const redirectUrl = returnUrl.toString();
60
+ console.log('[Token] Redirecting to:', redirectUrl);
61
+
62
+ // Show retry button after a delay in case the redirect was cancelled (e.g. custom protocol dialog)
63
+ setTimeout(() => {
64
+ updateStatus('If you were not redirected, <a href="' + redirectUrl + '">click here to try again</a>.');
65
+ }, 3000);
66
+
67
+ window.location.href = redirectUrl;
55
68
  } else {
56
69
  // Add token to current URL (for browser extensions)
57
70
  // Extension background will detect this and close the tab
@@ -104,4 +117,14 @@ export default function (Manager) {
104
117
  $status.classList.add('d-none');
105
118
  }
106
119
  }
120
+
121
+ // LEGACY: Reformat token for desktop app deep links
122
+ // Legacy desktop apps expect ?payload={"token":"X"} instead of ?authToken=X for custom protocol URLs
123
+ // TODO: Remove this function AND its call above when legacy desktop app support is no longer needed
124
+ function _legacyTranslateTokenRedirect(returnUrl, token) {
125
+ if (returnUrl.protocol !== 'http:' && returnUrl.protocol !== 'https:') {
126
+ returnUrl.searchParams.delete('authToken');
127
+ returnUrl.searchParams.set('payload', JSON.stringify({ token: token }));
128
+ }
129
+ }
107
130
  }
@@ -0,0 +1,16 @@
1
+ ---
2
+ ### ALL PAGES ###
3
+ layout: themes/[ site.theme.id ]/frontend/pages/portal/email-preferences
4
+
5
+ ### REGULAR PAGES ###
6
+ meta:
7
+ title: "Email Preferences - {{ site.brand.name }}"
8
+ description: "Manage your email preferences for {{ site.brand.name }}."
9
+ breadcrumb: "Email Preferences"
10
+
11
+ ### MISC ###
12
+ sitemap:
13
+ include: false
14
+ ---
15
+
16
+ {{ content | uj_content_format }}
@@ -0,0 +1,108 @@
1
+ ---
2
+ ### ALL PAGES ###
3
+ layout: themes/[ site.theme.id ]/frontend/core/cover
4
+
5
+ ### PAGE CONFIG ###
6
+ prerender_icons:
7
+ - name: "bell-slash"
8
+ - name: "bell"
9
+ ---
10
+
11
+ <section class="col-12 col-md-8 col-lg-6 col-xl-5 mw-sm">
12
+ <div class="card border-0 shadow-lg">
13
+ <div class="card-body p-3 p-md-5">
14
+ <!-- Logo -->
15
+ <div class="text-center mb-3">
16
+ <div class="avatar avatar-xl">
17
+ <img src="{{ site.brand.images.brandmark }}?cb={{ site.uj.cache_breaker }}" alt="{{ site.brand.name }} Logo"/>
18
+ </div>
19
+ </div>
20
+
21
+ <!-- Header -->
22
+ <div class="text-center mb-4">
23
+ <h1 class="h3 mb-2">Email Preferences</h1>
24
+ <p class="text-muted" id="email-preferences-description">Loading your preferences...</p>
25
+ </div>
26
+
27
+ <!-- Error state (hidden by default) -->
28
+ <div id="email-preferences-error" class="text-center" hidden>
29
+ <div class="alert alert-danger mb-4">
30
+ {% uj_icon "triangle-exclamation", "me-1" %}
31
+ <span id="email-preferences-error-message">Invalid or missing parameters.</span>
32
+ </div>
33
+ <a href="/" class="btn btn-outline-adaptive">
34
+ {% uj_icon "arrow-left", "me-1" %}
35
+ Back to Home
36
+ </a>
37
+ </div>
38
+
39
+ <!-- Form (hidden until params are validated by JS) -->
40
+ <div id="email-preferences-content" hidden>
41
+ <!-- Email display -->
42
+ <div class="bg-body-tertiary rounded-3 p-3 mb-4 text-center">
43
+ <small class="text-muted d-block mb-1">Managing preferences for</small>
44
+ <strong id="email-preferences-address" class="text-break"></strong>
45
+ </div>
46
+
47
+ <!-- Email Preferences Form -->
48
+ <form id="email-preferences-form" autocomplete="off" onsubmit="return false">
49
+ <!-- Action selector -->
50
+ <div class="mb-4">
51
+ <div class="d-flex gap-2">
52
+ <button type="button" class="btn btn-outline-adaptive flex-fill active" data-action="unsubscribe">
53
+ {% uj_icon "bell-slash", "me-1" %}
54
+ Unsubscribe
55
+ </button>
56
+ <button type="button" class="btn btn-outline-adaptive flex-fill" data-action="resubscribe">
57
+ {% uj_icon "bell", "me-1" %}
58
+ Resubscribe
59
+ </button>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Info message (changes based on action) -->
64
+ <div class="alert alert-warning small mb-4" id="email-preferences-info-unsubscribe">
65
+ {% uj_icon "circle-info", "me-1" %}
66
+ You will stop receiving emails of this type. Other important account emails may still be sent.
67
+ </div>
68
+ <div class="alert alert-success small mb-4" id="email-preferences-info-resubscribe" hidden>
69
+ {% uj_icon "circle-info", "me-1" %}
70
+ You will start receiving these emails again.
71
+ </div>
72
+
73
+ <div class="mb-4 text-start">
74
+ <label for="email_confirm" class="form-label fw-semibold">
75
+ Confirm your email address <span class="text-danger">*</span>
76
+ </label>
77
+ <input type="email" class="form-control form-control-md" id="email_confirm" name="email_confirm"
78
+ placeholder="Type your email to confirm"
79
+ autocomplete="email"
80
+ autofocus
81
+ required
82
+ disabled>
83
+ <div class="invalid-feedback"></div>
84
+ </div>
85
+
86
+ <!-- Honeypot field (bot detection) -->
87
+ <div class="form-group mb-3" style="display: none;" aria-hidden="true">
88
+ <input type="text" class="form-control" name="honey" tabindex="-1" autocomplete="off">
89
+ </div>
90
+
91
+ <button type="submit" class="btn btn-danger w-100 mb-4" id="email-preferences-submit" disabled>
92
+ <span class="button-text">Unsubscribe</span>
93
+ </button>
94
+ </form>
95
+
96
+ <!-- Back to Home -->
97
+ <div class="mt-3 pt-3 border-top text-center">
98
+ <a href="/" class="btn btn-outline-adaptive btn-sm">
99
+ {% uj_icon "arrow-left", "me-1" %}
100
+ Back to Home
101
+ </a>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </section>
107
+
108
+ {{ content | uj_content_format }}
@@ -0,0 +1,7 @@
1
+ ---
2
+ ### ALL PAGES ###
3
+ layout: blueprint/portal/email-preferences
4
+ permalink: /portal/email-preferences
5
+
6
+ ### REGULAR PAGES ###
7
+ ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "0.0.281",
3
+ "version": "0.0.283",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {