backend-manager 5.0.148 → 5.0.149
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 +50 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* DELETE /marketing/contact - Remove marketing contact
|
|
3
3
|
* Admin-only endpoint to unsubscribe from newsletter
|
|
4
4
|
*/
|
|
5
|
-
const fetch = require('wonderful-fetch');
|
|
6
5
|
|
|
7
6
|
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
8
7
|
|
|
@@ -26,21 +25,9 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
26
25
|
return assistant.respond('Email is required', { code: 400 });
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
// Get brand name from Manager config
|
|
30
|
-
const brandName = Manager.config.brand?.name;
|
|
31
|
-
|
|
32
28
|
// Remove from providers
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
// SendGrid
|
|
36
|
-
if (providers.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
|
|
37
|
-
providerResults.sendgrid = await removeFromSendGrid(email);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Beehiiv
|
|
41
|
-
if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
42
|
-
providerResults.beehiiv = await removeFromBeehiiv(email, brandName);
|
|
43
|
-
}
|
|
29
|
+
const mailer = Manager.Email(assistant);
|
|
30
|
+
const providerResults = await mailer.remove(email, { providers });
|
|
44
31
|
|
|
45
32
|
// Log result
|
|
46
33
|
assistant.log('marketing/contact delete result:', {
|
|
@@ -56,152 +43,3 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
56
43
|
providers: providerResults,
|
|
57
44
|
});
|
|
58
45
|
};
|
|
59
|
-
|
|
60
|
-
// Helper: Remove contact from SendGrid
|
|
61
|
-
async function removeFromSendGrid(email) {
|
|
62
|
-
try {
|
|
63
|
-
// Step 1: Get contact ID by email
|
|
64
|
-
const searchData = await fetch('https://api.sendgrid.com/v3/marketing/contacts/search/emails', {
|
|
65
|
-
method: 'post',
|
|
66
|
-
response: 'json',
|
|
67
|
-
headers: {
|
|
68
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
69
|
-
},
|
|
70
|
-
timeout: 10000,
|
|
71
|
-
body: { emails: [email] },
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
if (!searchData.result?.[email]?.contact?.id) {
|
|
75
|
-
return { success: true, skipped: true, reason: 'Contact not found' };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const contactId = searchData.result[email].contact.id;
|
|
79
|
-
|
|
80
|
-
// Step 2: Delete contact by ID
|
|
81
|
-
const deleteData = await fetch(`https://api.sendgrid.com/v3/marketing/contacts?ids=${contactId}`, {
|
|
82
|
-
method: 'delete',
|
|
83
|
-
response: 'json',
|
|
84
|
-
headers: {
|
|
85
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
86
|
-
},
|
|
87
|
-
timeout: 10000,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
if (deleteData.job_id) {
|
|
91
|
-
return { success: true, jobId: deleteData.job_id };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return { success: false, error: deleteData.errors?.[0]?.message || 'Delete failed' };
|
|
95
|
-
} catch (e) {
|
|
96
|
-
console.error('SendGrid remove error:', e);
|
|
97
|
-
return { success: false, error: e.message };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Helper: Get Beehiiv publication ID by brand name
|
|
102
|
-
async function getBeehiivPublicationId(brandName) {
|
|
103
|
-
if (!brandName) {
|
|
104
|
-
console.error('Beehiiv: Brand name is required to find publication');
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const brandNameLower = brandName.toLowerCase();
|
|
109
|
-
const allPublications = [];
|
|
110
|
-
let page = 1;
|
|
111
|
-
const limit = 100;
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
while (true) {
|
|
115
|
-
const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
|
|
116
|
-
response: 'json',
|
|
117
|
-
headers: {
|
|
118
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
119
|
-
},
|
|
120
|
-
timeout: 10000,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (!data.data || data.data.length === 0) {
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const matchedPub = data.data.find(pub =>
|
|
128
|
-
pub.name.toLowerCase() === brandNameLower
|
|
129
|
-
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
130
|
-
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
if (matchedPub) {
|
|
134
|
-
return matchedPub.id;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
allPublications.push(...data.data);
|
|
138
|
-
|
|
139
|
-
if (data.data.length < limit) {
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
page++;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
147
|
-
} catch (e) {
|
|
148
|
-
console.error('Beehiiv publication lookup error:', e);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Helper: Remove contact from Beehiiv
|
|
155
|
-
async function removeFromBeehiiv(email, brandName) {
|
|
156
|
-
try {
|
|
157
|
-
const pubId = await getBeehiivPublicationId(brandName);
|
|
158
|
-
if (!pubId) {
|
|
159
|
-
return { success: false, error: `Publication not found for brand "${brandName}"` };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Step 1: Get subscription by email
|
|
163
|
-
const encodedEmail = encodeURIComponent(email);
|
|
164
|
-
|
|
165
|
-
let searchData;
|
|
166
|
-
try {
|
|
167
|
-
searchData = await fetch(
|
|
168
|
-
`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/by_email/${encodedEmail}`,
|
|
169
|
-
{
|
|
170
|
-
response: 'json',
|
|
171
|
-
headers: {
|
|
172
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
173
|
-
},
|
|
174
|
-
timeout: 10000,
|
|
175
|
-
}
|
|
176
|
-
);
|
|
177
|
-
} catch (e) {
|
|
178
|
-
if (e.status === 404) {
|
|
179
|
-
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
180
|
-
}
|
|
181
|
-
throw e;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (!searchData.data?.id) {
|
|
185
|
-
return { success: true, skipped: true, reason: 'Subscription not found' };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const subscriptionId = searchData.data.id;
|
|
189
|
-
|
|
190
|
-
// Step 2: Permanently DELETE the subscription
|
|
191
|
-
await fetch(
|
|
192
|
-
`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/${subscriptionId}`,
|
|
193
|
-
{
|
|
194
|
-
method: 'delete',
|
|
195
|
-
headers: {
|
|
196
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
197
|
-
},
|
|
198
|
-
timeout: 10000,
|
|
199
|
-
}
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
return { success: true, deleted: true, subscriptionId };
|
|
203
|
-
} catch (e) {
|
|
204
|
-
console.error('Beehiiv remove error:', e);
|
|
205
|
-
return { success: false, error: e.message };
|
|
206
|
-
}
|
|
207
|
-
}
|
|
@@ -2,15 +2,10 @@
|
|
|
2
2
|
* POST /marketing/contact - Add marketing contact
|
|
3
3
|
* Public endpoint to subscribe to newsletter, with admin options
|
|
4
4
|
*/
|
|
5
|
-
const fetch = require('wonderful-fetch');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const dns = require('dns').promises;
|
|
8
5
|
const recaptcha = require('../../../libraries/recaptcha.js');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
|
|
13
|
-
const { inferContact } = require(path.join(__dirname, '..', '..', '..', 'libraries', 'infer-contact.js'));
|
|
6
|
+
const { validate: validateEmail, ALL_CHECKS } = require('../../../libraries/email/validation.js');
|
|
7
|
+
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
8
|
+
const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
|
|
14
9
|
|
|
15
10
|
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
16
11
|
|
|
@@ -28,21 +23,44 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
28
23
|
|
|
29
24
|
// Admin-only options
|
|
30
25
|
const tags = isAdmin ? settings.tags : [];
|
|
31
|
-
const providers = isAdmin ? settings.providers :
|
|
26
|
+
const providers = isAdmin ? settings.providers : DEFAULT_PROVIDERS;
|
|
32
27
|
const skipValidation = isAdmin ? settings.skipValidation : false;
|
|
33
28
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
// Email validation — run free checks before reCAPTCHA/rate limit
|
|
30
|
+
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
31
|
+
|
|
32
|
+
// skipValidation (admin-only) reduces to just format + disposable
|
|
33
|
+
// Admin gets full checks including mailbox verification when external APIs are enabled
|
|
34
|
+
const checks = skipValidation
|
|
35
|
+
? ['format']
|
|
36
|
+
: (isAdmin && shouldCallExternalAPIs ? ALL_CHECKS : undefined);
|
|
37
|
+
|
|
38
|
+
const validation = await validateEmail(email, { checks });
|
|
39
|
+
|
|
40
|
+
if (!validation.valid) {
|
|
41
|
+
// For public requests, return generic success to prevent email enumeration
|
|
42
|
+
if (!isAdmin) {
|
|
43
|
+
return assistant.respond({ success: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { format, localPart, disposable } = validation.checks;
|
|
47
|
+
|
|
48
|
+
if (format && !format.valid) {
|
|
49
|
+
return assistant.respond('Invalid email format', { code: 400 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (localPart && !localPart.valid) {
|
|
53
|
+
return assistant.respond(`Blocked email local part: ${localPart.localPart}`, { code: 400 });
|
|
54
|
+
}
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
if (disposable && !disposable.valid) {
|
|
57
|
+
return assistant.respond(`Disposable email domain not allowed: ${disposable.domain}`, { code: 400 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return assistant.respond('Email validation failed', { code: 400 });
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
// Public access protection
|
|
63
|
+
// Public access protection (after validation so we don't waste reCAPTCHA on garbage)
|
|
46
64
|
if (!isAdmin) {
|
|
47
65
|
// Verify reCAPTCHA (skip during automated tests)
|
|
48
66
|
if (!assistant.isTesting()) {
|
|
@@ -67,37 +85,6 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
|
|
70
|
-
// Email validation
|
|
71
|
-
const validation = { valid: true, checks: {} };
|
|
72
|
-
|
|
73
|
-
// Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
|
|
74
|
-
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
75
|
-
|
|
76
|
-
if (!skipValidation) {
|
|
77
|
-
// Check disposable domain
|
|
78
|
-
const domain = email.split('@')[1];
|
|
79
|
-
if (DISPOSABLE_SET.has(domain.toLowerCase())) {
|
|
80
|
-
validation.valid = false;
|
|
81
|
-
validation.checks.disposable = { blocked: true, domain };
|
|
82
|
-
|
|
83
|
-
// For public requests, return generic success to prevent enumeration
|
|
84
|
-
if (!isAdmin) {
|
|
85
|
-
return assistant.respond({ success: true });
|
|
86
|
-
}
|
|
87
|
-
return assistant.respond(`Disposable email domain not allowed: ${domain}`, { code: 400 });
|
|
88
|
-
}
|
|
89
|
-
validation.checks.disposable = { blocked: false };
|
|
90
|
-
|
|
91
|
-
// ZeroBounce validation (admin only, if key exists)
|
|
92
|
-
if (isAdmin && process.env.ZEROBOUNCE_API_KEY && shouldCallExternalAPIs) {
|
|
93
|
-
const zbResult = await validateWithZeroBounce(email);
|
|
94
|
-
validation.checks.zerobounce = zbResult;
|
|
95
|
-
if (!zbResult.valid) {
|
|
96
|
-
validation.valid = false;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
88
|
// Infer name if not provided
|
|
102
89
|
let nameInferred = null;
|
|
103
90
|
if (!firstName && !lastName) {
|
|
@@ -107,35 +94,19 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
107
94
|
}
|
|
108
95
|
|
|
109
96
|
// Add to providers
|
|
110
|
-
|
|
97
|
+
let providerResults = {};
|
|
111
98
|
|
|
112
99
|
if (!shouldCallExternalAPIs) {
|
|
113
100
|
assistant.log('marketing/contact: Skipping providers (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
|
|
114
101
|
} else {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
source,
|
|
124
|
-
appId: Manager.config.app.id,
|
|
125
|
-
brandName: Manager.config.brand?.name,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Beehiiv
|
|
130
|
-
if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
131
|
-
providerResults.beehiiv = await addToBeehiiv({
|
|
132
|
-
email,
|
|
133
|
-
firstName,
|
|
134
|
-
lastName,
|
|
135
|
-
source,
|
|
136
|
-
brandName: Manager.config.brand?.name,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
102
|
+
const mailer = Manager.Email(assistant);
|
|
103
|
+
providerResults = await mailer.add({
|
|
104
|
+
email,
|
|
105
|
+
firstName,
|
|
106
|
+
lastName,
|
|
107
|
+
source,
|
|
108
|
+
providers,
|
|
109
|
+
});
|
|
139
110
|
}
|
|
140
111
|
|
|
141
112
|
// Log result
|
|
@@ -162,227 +133,3 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
162
133
|
// Public: generic response
|
|
163
134
|
return assistant.respond({ success: true });
|
|
164
135
|
};
|
|
165
|
-
|
|
166
|
-
// Helper: Validate email with ZeroBounce API
|
|
167
|
-
async function validateWithZeroBounce(email) {
|
|
168
|
-
try {
|
|
169
|
-
const data = await fetch(
|
|
170
|
-
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
|
|
171
|
-
{ response: 'json', timeout: 10000 }
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
if (data.error) {
|
|
175
|
-
console.error('ZeroBounce API error:', data.error);
|
|
176
|
-
return { valid: true, error: data.error };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!data.status) {
|
|
180
|
-
console.error('ZeroBounce unexpected response:', data);
|
|
181
|
-
return { valid: true, error: 'Unexpected response format' };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
valid: data.status === 'valid',
|
|
186
|
-
status: data.status,
|
|
187
|
-
subStatus: data.sub_status || null,
|
|
188
|
-
};
|
|
189
|
-
} catch (e) {
|
|
190
|
-
console.error('ZeroBounce validation error:', e);
|
|
191
|
-
return { valid: true, error: e.message };
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Helper: Add contact to SendGrid
|
|
196
|
-
async function addToSendGrid({ email, firstName, lastName, source, appId, brandName }) {
|
|
197
|
-
try {
|
|
198
|
-
const listId = await getSendGridListId(brandName);
|
|
199
|
-
|
|
200
|
-
const requestBody = {
|
|
201
|
-
contacts: [
|
|
202
|
-
{
|
|
203
|
-
email,
|
|
204
|
-
first_name: firstName || undefined,
|
|
205
|
-
last_name: lastName || undefined,
|
|
206
|
-
custom_fields: {
|
|
207
|
-
e1_T: source,
|
|
208
|
-
e2_T: appId,
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
],
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
if (listId) {
|
|
215
|
-
requestBody.list_ids = [listId];
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const data = await fetch('https://api.sendgrid.com/v3/marketing/contacts', {
|
|
219
|
-
method: 'put',
|
|
220
|
-
response: 'json',
|
|
221
|
-
headers: {
|
|
222
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
223
|
-
},
|
|
224
|
-
timeout: 15000,
|
|
225
|
-
body: requestBody,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
if (data.job_id) {
|
|
229
|
-
return { success: true, jobId: data.job_id, listId };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return { success: false, error: data.errors?.[0]?.message || 'Unknown error' };
|
|
233
|
-
} catch (e) {
|
|
234
|
-
console.error('SendGrid error:', e);
|
|
235
|
-
return { success: false, error: e.message };
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Helper: Get SendGrid list ID by brand name
|
|
240
|
-
async function getSendGridListId(brandName) {
|
|
241
|
-
const brandNameLower = (brandName || '').toLowerCase();
|
|
242
|
-
const allLists = [];
|
|
243
|
-
let pageToken = '';
|
|
244
|
-
const pageSize = 1000;
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
while (true) {
|
|
248
|
-
const url = `https://api.sendgrid.com/v3/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
|
|
249
|
-
const data = await fetch(url, {
|
|
250
|
-
response: 'json',
|
|
251
|
-
headers: {
|
|
252
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
253
|
-
},
|
|
254
|
-
timeout: 10000,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
if (!data.result || data.result.length === 0) {
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const matchedList = data.result.find(list =>
|
|
262
|
-
list.name.toLowerCase() === brandNameLower
|
|
263
|
-
|| list.name.toLowerCase().includes(brandNameLower)
|
|
264
|
-
|| brandNameLower.includes(list.name.toLowerCase())
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
if (matchedList) {
|
|
268
|
-
return matchedList.id;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
allLists.push(...data.result);
|
|
272
|
-
|
|
273
|
-
if (!data._metadata?.next) {
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const nextUrl = new URL(data._metadata.next);
|
|
278
|
-
pageToken = nextUrl.searchParams.get('page_token');
|
|
279
|
-
|
|
280
|
-
if (!pageToken) {
|
|
281
|
-
break;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (allLists.length === 1) {
|
|
286
|
-
return allLists[0].id;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (allLists.length > 0) {
|
|
290
|
-
console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
|
|
291
|
-
}
|
|
292
|
-
} catch (e) {
|
|
293
|
-
console.error('SendGrid list lookup error:', e);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Helper: Add contact to Beehiiv
|
|
300
|
-
async function addToBeehiiv({ email, firstName, lastName, source, brandName }) {
|
|
301
|
-
try {
|
|
302
|
-
const pubId = await getBeehiivPublicationId(brandName);
|
|
303
|
-
if (!pubId) {
|
|
304
|
-
return { success: false, error: 'Could not find matching publication' };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const data = await fetch(`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions`, {
|
|
308
|
-
method: 'post',
|
|
309
|
-
response: 'json',
|
|
310
|
-
headers: {
|
|
311
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
312
|
-
},
|
|
313
|
-
timeout: 15000,
|
|
314
|
-
body: {
|
|
315
|
-
email,
|
|
316
|
-
reactivate_existing: true,
|
|
317
|
-
send_welcome_email: true,
|
|
318
|
-
utm_source: source,
|
|
319
|
-
custom_fields: [
|
|
320
|
-
firstName ? { name: 'first_name', value: firstName } : null,
|
|
321
|
-
lastName ? { name: 'last_name', value: lastName } : null,
|
|
322
|
-
].filter(Boolean),
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
if (data.data?.id) {
|
|
327
|
-
return { success: true, id: data.data.id };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return { success: false, error: data.message || 'Unknown error' };
|
|
331
|
-
} catch (e) {
|
|
332
|
-
console.error('Beehiiv error:', e);
|
|
333
|
-
return { success: false, error: e.message };
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Helper: Get Beehiiv publication ID by brand name
|
|
338
|
-
async function getBeehiivPublicationId(brandName) {
|
|
339
|
-
if (!brandName) {
|
|
340
|
-
console.error('Beehiiv: Brand name is required to find publication');
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const brandNameLower = brandName.toLowerCase();
|
|
345
|
-
const allPublications = [];
|
|
346
|
-
let page = 1;
|
|
347
|
-
const limit = 100;
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
while (true) {
|
|
351
|
-
const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
|
|
352
|
-
response: 'json',
|
|
353
|
-
headers: {
|
|
354
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
355
|
-
},
|
|
356
|
-
timeout: 10000,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
if (!data.data || data.data.length === 0) {
|
|
360
|
-
break;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const matchedPub = data.data.find(pub =>
|
|
364
|
-
pub.name.toLowerCase() === brandNameLower
|
|
365
|
-
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
366
|
-
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
367
|
-
);
|
|
368
|
-
|
|
369
|
-
if (matchedPub) {
|
|
370
|
-
return matchedPub.id;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
allPublications.push(...data.data);
|
|
374
|
-
|
|
375
|
-
if (data.data.length < limit) {
|
|
376
|
-
break;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
page++;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
383
|
-
} catch (e) {
|
|
384
|
-
console.error('Beehiiv publication lookup error:', e);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /marketing/contact - Sync marketing contact by UID
|
|
3
|
+
* Admin-only endpoint to re-sync a user's data to marketing providers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
7
|
+
|
|
8
|
+
// Initialize Usage to check auth level
|
|
9
|
+
const usage = await Manager.Usage().init(assistant, {
|
|
10
|
+
unauthenticatedMode: 'firestore',
|
|
11
|
+
});
|
|
12
|
+
const isAdmin = usage.user.roles?.admin;
|
|
13
|
+
|
|
14
|
+
// Admin only endpoint
|
|
15
|
+
if (!isAdmin) {
|
|
16
|
+
return assistant.respond('Admin access required', { code: 403 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const uid = (settings.uid || '').trim();
|
|
20
|
+
|
|
21
|
+
if (!uid) {
|
|
22
|
+
return assistant.respond('UID is required', { code: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Sync via email library (accepts UID string, resolves user doc internally)
|
|
26
|
+
const mailer = Manager.Email(assistant);
|
|
27
|
+
const result = await mailer.sync(uid);
|
|
28
|
+
|
|
29
|
+
// Log result
|
|
30
|
+
assistant.log('marketing/contact sync result:', { uid, providers: result });
|
|
31
|
+
|
|
32
|
+
// Track analytics
|
|
33
|
+
analytics.event('marketing/contact', { action: 'sync' });
|
|
34
|
+
|
|
35
|
+
return assistant.respond({
|
|
36
|
+
success: true,
|
|
37
|
+
providers: result,
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -32,6 +32,17 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
32
32
|
return assistant.respond('No active paid subscription found', { code: 400 });
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Guard: subscription younger than 24 hours
|
|
36
|
+
const startDateUNIX = subscription.payment?.startDate?.timestampUNIX;
|
|
37
|
+
if (startDateUNIX) {
|
|
38
|
+
const ageMs = Date.now() - startDateUNIX;
|
|
39
|
+
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
|
|
40
|
+
if (ageMs < twentyFourHoursMs) {
|
|
41
|
+
assistant.log(`Cancel rejected: uid=${uid}, subscription is only ${Math.round(ageMs / 1000 / 60)} minutes old`);
|
|
42
|
+
return assistant.respond('Your subscription is still being set up. Please try again after 24-48 hours.', { code: 400 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
// Guard: already pending cancellation
|
|
36
47
|
if (subscription.cancellation?.pending === true) {
|
|
37
48
|
assistant.log(`Cancel rejected: uid=${uid}, cancellation already pending`);
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Returns client configuration with optional auth
|
|
4
4
|
*/
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { buildPublicConfig } = require(path.join(__dirname, '..', '..', '
|
|
6
|
+
const { buildPublicConfig } = require(path.join(__dirname, '..', '..', 'brand', 'get.js'));
|
|
7
7
|
|
|
8
8
|
module.exports = async ({ assistant, Manager, settings, analytics, libraries }) => {
|
|
9
9
|
const { admin } = libraries;
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// brandId/brand fallback to Manager.config
|
|
12
12
|
let uid = settings.uid;
|
|
13
13
|
let config = settings.config;
|
|
14
14
|
|
|
@@ -52,7 +52,7 @@ module.exports = async ({ assistant, Manager, settings, analytics, libraries })
|
|
|
52
52
|
timestamp: new Date().toISOString(),
|
|
53
53
|
ip: assistant.request.geolocation.ip,
|
|
54
54
|
country: assistant.request.geolocation.country,
|
|
55
|
-
|
|
55
|
+
brand: buildPublicConfig(Manager.config),
|
|
56
56
|
config: config,
|
|
57
57
|
});
|
|
58
58
|
};
|
|
@@ -6,6 +6,7 @@ module.exports = async ({ assistant, Manager }) => {
|
|
|
6
6
|
environment: assistant.meta?.environment || 'unknown',
|
|
7
7
|
version: Manager.package?.version || 'unknown',
|
|
8
8
|
bemVersion: Manager.version || 'unknown',
|
|
9
|
+
testExtendedMode: !!process.env.TEST_EXTENDED_MODE,
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
assistant.log('Health check', response);
|
|
@@ -50,11 +50,11 @@ function sendCancellationEmail(assistant, user, requestId) {
|
|
|
50
50
|
const uid = user.auth.uid;
|
|
51
51
|
|
|
52
52
|
mailer.send({
|
|
53
|
-
to: user
|
|
53
|
+
to: user,
|
|
54
|
+
sender: 'account',
|
|
54
55
|
categories: ['account/data-request-cancelled'],
|
|
55
56
|
subject: `Your data request has been cancelled #${requestId}`,
|
|
56
57
|
template: 'default',
|
|
57
|
-
group: 'account',
|
|
58
58
|
copy: true,
|
|
59
59
|
data: {
|
|
60
60
|
email: {
|
|
@@ -193,11 +193,11 @@ function sendDownloadEmail(assistant, user, requestId, downloads) {
|
|
|
193
193
|
const downloadDate = assistant.meta.startTime.timestamp;
|
|
194
194
|
|
|
195
195
|
mailer.send({
|
|
196
|
-
to: user
|
|
196
|
+
to: user,
|
|
197
|
+
sender: 'account',
|
|
197
198
|
categories: ['account/data-request-download'],
|
|
198
199
|
subject: `Your data has been downloaded #${requestId}`,
|
|
199
200
|
template: 'default',
|
|
200
|
-
group: 'account',
|
|
201
201
|
copy: true,
|
|
202
202
|
data: {
|
|
203
203
|
email: {
|