backend-manager 5.0.118 → 5.0.119
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 +28 -0
- package/package.json +1 -1
- package/src/manager/libraries/email.js +5 -1
- package/src/manager/routes/marketing/email-preferences/post.js +108 -0
- package/src/manager/schemas/marketing/email-preferences/post.js +9 -0
- package/templates/_.env +1 -0
- package/test/routes/marketing/email-preferences.js +216 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.119] - 2026-03-07
|
|
18
|
+
### Added
|
|
19
|
+
- `POST /marketing/email-preferences` route for unsubscribe/resubscribe via SendGrid ASM suppression groups
|
|
20
|
+
- HMAC signature verification (`UNSUBSCRIBE_HMAC_KEY`) on unsubscribe links to prevent forged requests
|
|
21
|
+
- HMAC signature generation in email library when building unsubscribe URLs
|
|
22
|
+
- `UNSUBSCRIBE_HMAC_KEY` environment variable in template `.env`
|
|
23
|
+
- Test suite for email-preferences endpoint (10 tests covering sig verification, validation, auth)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Unsubscribe URL in emails no longer includes `appName` and `appUrl` params (replaced by HMAC sig)
|
|
27
|
+
|
|
28
|
+
# [5.0.118] - 2026-03-06
|
|
29
|
+
### Added
|
|
30
|
+
- Chargebee payment processor with full pipeline support (intent, webhook, cancel, refund, portal).
|
|
31
|
+
- Chargebee shared library (`payment/processors/chargebee.js`) with raw HTTP API wrapper, unified subscription/one-time transformers, and both Items model (new) and Plans model (legacy) product resolution.
|
|
32
|
+
- Chargebee webhook processor supporting subscription lifecycle events (`subscription_created`, `subscription_cancelled`, `subscription_renewed`, `payment_failed`, `payment_refunded`, etc.) and one-time invoice events.
|
|
33
|
+
- Chargebee intent processor for hosted page checkout (subscriptions and one-time purchases) with deterministic item price IDs (`{itemId}-{frequency}`).
|
|
34
|
+
- Chargebee cancel processor with immediate cancellation during trials and end-of-term cancellation otherwise.
|
|
35
|
+
- Chargebee refund processor with 7-day full/prorated refund logic (matching Stripe/PayPal behavior).
|
|
36
|
+
- Chargebee portal processor for self-service subscription management via Chargebee Portal Sessions.
|
|
37
|
+
- Backwards compatibility for legacy Chargebee subscriptions: reads `cf_clientorderid`/`cf_uid` custom fields alongside new `meta_data` JSON format.
|
|
38
|
+
- Chargebee test suite: `to-unified-subscription`, `to-unified-one-time`, and `parse-webhook` group tests with fixtures covering all status mappings, product resolution (Items + legacy Plans), and edge cases.
|
|
39
|
+
- Chargebee customer name extraction from `shipping_address`/`billing_address` in webhook on-write pipeline.
|
|
40
|
+
- `chargebee` config keys in product templates (`itemId`, `legacyPlanIds`).
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- `CHARGEBEE_SITE` environment variable is now set from config in Manager init (matching PayPal pattern), so the Chargebee library doesn't need a Manager reference.
|
|
44
|
+
|
|
17
45
|
# [5.0.111] - 2026-03-05
|
|
18
46
|
### Changed
|
|
19
47
|
- PayPal client ID is now read from `backend-manager-config.json` (`payment.processors.paypal.clientId`) instead of requiring a `PAYPAL_CLIENT_ID` environment variable.
|
package/package.json
CHANGED
|
@@ -161,7 +161,11 @@ Email.prototype.build = async function (settings) {
|
|
|
161
161
|
const sendAt = normalizeSendAt(settings.sendAt);
|
|
162
162
|
|
|
163
163
|
// Build unsubscribe URL
|
|
164
|
-
|
|
164
|
+
// Generate HMAC signature for unsubscribe link verification
|
|
165
|
+
const crypto = require('crypto');
|
|
166
|
+
const unsubSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(to[0].email.toLowerCase()).digest('hex');
|
|
167
|
+
|
|
168
|
+
const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&sig=${unsubSig}`;
|
|
165
169
|
|
|
166
170
|
// Build signoff
|
|
167
171
|
const signoff = settings?.data?.signoff || {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /marketing/email-preferences - Update email preferences
|
|
3
|
+
* Public endpoint — no authentication required
|
|
4
|
+
*
|
|
5
|
+
* Supports two actions:
|
|
6
|
+
* - "unsubscribe": Adds email to SendGrid ASM suppression group
|
|
7
|
+
* - "resubscribe": Removes email from SendGrid ASM suppression group
|
|
8
|
+
*/
|
|
9
|
+
const fetch = require('wonderful-fetch');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
const RATE_LIMIT = 5;
|
|
13
|
+
|
|
14
|
+
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
15
|
+
|
|
16
|
+
// Extract parameters
|
|
17
|
+
const email = (settings.email || '').trim().toLowerCase();
|
|
18
|
+
const asmId = parseInt(settings.asmId, 10);
|
|
19
|
+
const action = settings.action || 'unsubscribe';
|
|
20
|
+
|
|
21
|
+
// Validate email
|
|
22
|
+
if (!email) {
|
|
23
|
+
return assistant.respond('Email is required', { code: 400 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
27
|
+
if (!emailRegex.test(email)) {
|
|
28
|
+
return assistant.respond('Invalid email format', { code: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Validate ASM group ID
|
|
32
|
+
if (!asmId || isNaN(asmId)) {
|
|
33
|
+
return assistant.respond('ASM group ID is required', { code: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validate action
|
|
37
|
+
if (action !== 'unsubscribe' && action !== 'resubscribe') {
|
|
38
|
+
return assistant.respond('Invalid action', { code: 400 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Verify HMAC signature (proves the link was generated by our server)
|
|
42
|
+
const expectedSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(email).digest('hex');
|
|
43
|
+
if (settings.sig !== expectedSig) {
|
|
44
|
+
return assistant.respond('Invalid signature', { code: 403 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Initialize Usage for rate limiting (key: IP forces unauthenticated storage always)
|
|
48
|
+
const usage = await Manager.Usage().init(assistant, {
|
|
49
|
+
unauthenticatedMode: 'firestore',
|
|
50
|
+
key: assistant.request.geolocation.ip,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Rate limiting (manual check since email-preferences isn't in product limits)
|
|
54
|
+
const currentUsage = usage.getUsage('email-preferences');
|
|
55
|
+
if (currentUsage >= RATE_LIMIT) {
|
|
56
|
+
return assistant.respond('Rate limit exceeded', { code: 429 });
|
|
57
|
+
}
|
|
58
|
+
usage.increment('email-preferences');
|
|
59
|
+
await usage.update();
|
|
60
|
+
|
|
61
|
+
// Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
|
|
62
|
+
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
63
|
+
|
|
64
|
+
if (!shouldCallExternalAPIs) {
|
|
65
|
+
assistant.log('marketing/email-preferences: Skipping SendGrid (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
|
|
66
|
+
return assistant.respond({ success: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Call SendGrid ASM API
|
|
70
|
+
try {
|
|
71
|
+
if (action === 'unsubscribe') {
|
|
72
|
+
// Add email to suppression group
|
|
73
|
+
await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
response: 'json',
|
|
76
|
+
headers: {
|
|
77
|
+
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
78
|
+
},
|
|
79
|
+
timeout: 10000,
|
|
80
|
+
body: {
|
|
81
|
+
recipient_emails: [email],
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
// Remove email from suppression group
|
|
86
|
+
await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions/${encodeURIComponent(email)}`, {
|
|
87
|
+
method: 'DELETE',
|
|
88
|
+
response: 'json',
|
|
89
|
+
headers: {
|
|
90
|
+
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
91
|
+
},
|
|
92
|
+
timeout: 10000,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
assistant.log(`SendGrid ASM ${action} error:`, e);
|
|
97
|
+
return assistant.respond('Failed to process your request', { code: 500 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Log result
|
|
101
|
+
assistant.log('marketing/email-preferences result:', { email, asmId, action });
|
|
102
|
+
|
|
103
|
+
// Track analytics
|
|
104
|
+
analytics.event('marketing/email-preferences', { action });
|
|
105
|
+
|
|
106
|
+
// Generic success (no sensitive info leakage)
|
|
107
|
+
return assistant.respond({ success: true });
|
|
108
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema for POST /marketing/email-preferences
|
|
3
|
+
*/
|
|
4
|
+
module.exports = () => ({
|
|
5
|
+
email: { types: ['string'], default: undefined, required: true },
|
|
6
|
+
asmId: { types: ['string', 'number'], default: undefined, required: true },
|
|
7
|
+
action: { types: ['string'], default: 'unsubscribe' },
|
|
8
|
+
sig: { types: ['string'], default: undefined, required: true },
|
|
9
|
+
});
|
package/templates/_.env
CHANGED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /marketing/email-preferences
|
|
3
|
+
* Tests the email preferences endpoint for unsubscribe/resubscribe via SendGrid ASM
|
|
4
|
+
*
|
|
5
|
+
* Set TEST_EXTENDED_MODE=true to run tests against real SendGrid ASM API
|
|
6
|
+
* (requires SENDGRID_API_KEY env var)
|
|
7
|
+
*/
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const TEST_EMAIL = 'rachel.greene+bem-unsub@gmail.com';
|
|
11
|
+
const TEST_ASM_ID = '24077';
|
|
12
|
+
|
|
13
|
+
function generateSig(email) {
|
|
14
|
+
return crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(email.toLowerCase()).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
description: 'Marketing email-preferences (POST unsubscribe/resubscribe)',
|
|
19
|
+
type: 'group',
|
|
20
|
+
tests: [
|
|
21
|
+
// Test 1: Successful unsubscribe with valid sig
|
|
22
|
+
{
|
|
23
|
+
name: 'unsubscribe-valid-sig-succeeds',
|
|
24
|
+
auth: 'none',
|
|
25
|
+
timeout: 15000,
|
|
26
|
+
|
|
27
|
+
async run({ http, assert }) {
|
|
28
|
+
const sig = generateSig(TEST_EMAIL);
|
|
29
|
+
|
|
30
|
+
const response = await http.post('marketing/email-preferences', {
|
|
31
|
+
email: TEST_EMAIL,
|
|
32
|
+
asmId: TEST_ASM_ID,
|
|
33
|
+
action: 'unsubscribe',
|
|
34
|
+
sig: sig,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.isSuccess(response, 'Unsubscribe with valid sig should succeed');
|
|
38
|
+
assert.propertyEquals(response, 'data.success', true, 'success should be true');
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Test 2: Successful resubscribe with valid sig
|
|
43
|
+
{
|
|
44
|
+
name: 'resubscribe-valid-sig-succeeds',
|
|
45
|
+
auth: 'none',
|
|
46
|
+
timeout: 15000,
|
|
47
|
+
|
|
48
|
+
async run({ http, assert }) {
|
|
49
|
+
const sig = generateSig(TEST_EMAIL);
|
|
50
|
+
|
|
51
|
+
const response = await http.post('marketing/email-preferences', {
|
|
52
|
+
email: TEST_EMAIL,
|
|
53
|
+
asmId: TEST_ASM_ID,
|
|
54
|
+
action: 'resubscribe',
|
|
55
|
+
sig: sig,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.isSuccess(response, 'Resubscribe with valid sig should succeed');
|
|
59
|
+
assert.propertyEquals(response, 'data.success', true, 'success should be true');
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Test 3: Invalid sig rejected
|
|
64
|
+
{
|
|
65
|
+
name: 'invalid-sig-rejected',
|
|
66
|
+
auth: 'none',
|
|
67
|
+
timeout: 15000,
|
|
68
|
+
|
|
69
|
+
async run({ http, assert }) {
|
|
70
|
+
const response = await http.post('marketing/email-preferences', {
|
|
71
|
+
email: TEST_EMAIL,
|
|
72
|
+
asmId: TEST_ASM_ID,
|
|
73
|
+
action: 'unsubscribe',
|
|
74
|
+
sig: 'invalid-signature-value',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.isError(response, 403, 'Invalid sig should return 403');
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Test 4: Missing sig rejected (schema requires it)
|
|
82
|
+
{
|
|
83
|
+
name: 'missing-sig-rejected',
|
|
84
|
+
auth: 'none',
|
|
85
|
+
timeout: 15000,
|
|
86
|
+
|
|
87
|
+
async run({ http, assert }) {
|
|
88
|
+
const response = await http.post('marketing/email-preferences', {
|
|
89
|
+
email: TEST_EMAIL,
|
|
90
|
+
asmId: TEST_ASM_ID,
|
|
91
|
+
action: 'unsubscribe',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.isError(response, 400, 'Missing sig should return 400');
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Test 5: Missing email rejected
|
|
99
|
+
{
|
|
100
|
+
name: 'missing-email-rejected',
|
|
101
|
+
auth: 'none',
|
|
102
|
+
timeout: 15000,
|
|
103
|
+
|
|
104
|
+
async run({ http, assert }) {
|
|
105
|
+
const response = await http.post('marketing/email-preferences', {
|
|
106
|
+
asmId: TEST_ASM_ID,
|
|
107
|
+
action: 'unsubscribe',
|
|
108
|
+
sig: 'anything',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.isError(response, 400, 'Missing email should return 400');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Test 6: Invalid email format rejected
|
|
116
|
+
{
|
|
117
|
+
name: 'invalid-email-rejected',
|
|
118
|
+
auth: 'none',
|
|
119
|
+
timeout: 15000,
|
|
120
|
+
|
|
121
|
+
async run({ http, assert }) {
|
|
122
|
+
const sig = generateSig('not-an-email');
|
|
123
|
+
|
|
124
|
+
const response = await http.post('marketing/email-preferences', {
|
|
125
|
+
email: 'not-an-email',
|
|
126
|
+
asmId: TEST_ASM_ID,
|
|
127
|
+
action: 'unsubscribe',
|
|
128
|
+
sig: sig,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
assert.isError(response, 400, 'Invalid email format should return 400');
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Test 7: Missing asmId rejected
|
|
136
|
+
{
|
|
137
|
+
name: 'missing-asmid-rejected',
|
|
138
|
+
auth: 'none',
|
|
139
|
+
timeout: 15000,
|
|
140
|
+
|
|
141
|
+
async run({ http, assert }) {
|
|
142
|
+
const sig = generateSig(TEST_EMAIL);
|
|
143
|
+
|
|
144
|
+
const response = await http.post('marketing/email-preferences', {
|
|
145
|
+
email: TEST_EMAIL,
|
|
146
|
+
action: 'unsubscribe',
|
|
147
|
+
sig: sig,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.isError(response, 400, 'Missing asmId should return 400');
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// Test 8: Invalid action rejected
|
|
155
|
+
{
|
|
156
|
+
name: 'invalid-action-rejected',
|
|
157
|
+
auth: 'none',
|
|
158
|
+
timeout: 15000,
|
|
159
|
+
|
|
160
|
+
async run({ http, assert }) {
|
|
161
|
+
const sig = generateSig(TEST_EMAIL);
|
|
162
|
+
|
|
163
|
+
const response = await http.post('marketing/email-preferences', {
|
|
164
|
+
email: TEST_EMAIL,
|
|
165
|
+
asmId: TEST_ASM_ID,
|
|
166
|
+
action: 'delete',
|
|
167
|
+
sig: sig,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.isError(response, 400, 'Invalid action should return 400');
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
// Test 9: Sig for different email rejected (proves per-email sig)
|
|
175
|
+
{
|
|
176
|
+
name: 'wrong-email-sig-rejected',
|
|
177
|
+
auth: 'none',
|
|
178
|
+
timeout: 15000,
|
|
179
|
+
|
|
180
|
+
async run({ http, assert }) {
|
|
181
|
+
// Generate sig for a different email
|
|
182
|
+
const sig = generateSig('someone-else@gmail.com');
|
|
183
|
+
|
|
184
|
+
const response = await http.post('marketing/email-preferences', {
|
|
185
|
+
email: TEST_EMAIL,
|
|
186
|
+
asmId: TEST_ASM_ID,
|
|
187
|
+
action: 'unsubscribe',
|
|
188
|
+
sig: sig,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.isError(response, 403, 'Sig for different email should return 403');
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
// Test 10: Authenticated user also works (sig is checked regardless of auth)
|
|
196
|
+
{
|
|
197
|
+
name: 'authenticated-user-with-valid-sig-succeeds',
|
|
198
|
+
auth: 'user',
|
|
199
|
+
timeout: 15000,
|
|
200
|
+
|
|
201
|
+
async run({ http, assert }) {
|
|
202
|
+
const sig = generateSig(TEST_EMAIL);
|
|
203
|
+
|
|
204
|
+
const response = await http.post('marketing/email-preferences', {
|
|
205
|
+
email: TEST_EMAIL,
|
|
206
|
+
asmId: TEST_ASM_ID,
|
|
207
|
+
action: 'unsubscribe',
|
|
208
|
+
sig: sig,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
assert.isSuccess(response, 'Authenticated user with valid sig should succeed');
|
|
212
|
+
assert.propertyEquals(response, 'data.success', true, 'success should be true');
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|