thumbgate 1.7.0 → 1.8.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +50 -1
- package/adapters/opencode/opencode.json +1 -1
- package/config/mcp-allowlists.json +10 -1
- package/package.json +11 -6
- package/public/index.html +2 -2
- package/scripts/autonomous-workflow.js +377 -0
- package/scripts/billing.js +4 -2
- package/scripts/mailer/resend-mailer.js +210 -40
- package/scripts/statusline-context.js +207 -0
- package/scripts/statusline.sh +31 -14
- package/scripts/tool-registry.js +39 -0
- package/CHANGELOG.md +0 -702
package/scripts/billing.js
CHANGED
|
@@ -511,6 +511,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
|
|
|
511
511
|
const origin = resolvePublicAppOrigin(appOrigin);
|
|
512
512
|
const dashboardUrl = joinPublicUrl(origin, '/dashboard');
|
|
513
513
|
const docsUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
|
|
514
|
+
const supportEmail = process.env.THUMBGATE_SUPPORT_EMAIL || CONFIG.TRIAL_EMAIL_REPLY_TO || 'igor.ganapolsky@gmail.com';
|
|
514
515
|
const command = `npx thumbgate pro --activate --key=${apiKey || ''}`;
|
|
515
516
|
const subject = 'Your 7-day ThumbGate Pro trial is live';
|
|
516
517
|
const preheader = 'Activate Pro in one command, open the dashboard, and start blocking repeated AI coding mistakes.';
|
|
@@ -519,6 +520,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
|
|
|
519
520
|
const exampleFeedback = 'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
|
|
520
521
|
const safeDashboardUrl = escapeHtml(dashboardUrl);
|
|
521
522
|
const safeDocsUrl = escapeHtml(docsUrl);
|
|
523
|
+
const safeSupportEmail = escapeHtml(supportEmail);
|
|
522
524
|
const safeCommand = escapeHtml(command);
|
|
523
525
|
const safeApiKey = escapeHtml(apiKey || '');
|
|
524
526
|
return {
|
|
@@ -544,7 +546,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
|
|
|
544
546
|
apiKey,
|
|
545
547
|
'',
|
|
546
548
|
`Verification evidence: ${docsUrl}`,
|
|
547
|
-
|
|
549
|
+
`Keep this key private. Questions? Reply to this email or write ${supportEmail}.`,
|
|
548
550
|
sessionId ? `Stripe session: ${sessionId}` : null,
|
|
549
551
|
planId ? `Plan: ${planId}` : null,
|
|
550
552
|
].filter(Boolean).join('\n'),
|
|
@@ -591,7 +593,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
|
|
|
591
593
|
|
|
592
594
|
<p style="margin:0;font-size:13px;line-height:1.6;color:#526273;">
|
|
593
595
|
Proof trail: <a href="${safeDocsUrl}" style="color:#087a91;">verification evidence</a>.
|
|
594
|
-
Keep this key private. Questions? Reply here or write <a href="mailto
|
|
596
|
+
Keep this key private. Questions? Reply here or write <a href="mailto:${safeSupportEmail}" style="color:#087a91;">${safeSupportEmail}</a>.
|
|
595
597
|
</p>
|
|
596
598
|
${sessionId ? `<p style="margin:12px 0 0;font-size:12px;line-height:1.5;color:#7a8790;">Stripe session: ${escapeHtml(sessionId)}</p>` : ''}
|
|
597
599
|
</td>
|
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
* address, and a functional unsubscribe method.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
const dns = require('node:dns').promises;
|
|
19
|
+
|
|
18
20
|
const PRODUCT_NAME = 'ThumbGate Pro';
|
|
19
21
|
const DASHBOARD_URL = 'https://thumbgate-production.up.railway.app/dashboard';
|
|
20
|
-
const
|
|
22
|
+
const DEFAULT_CONTACT_EMAIL = 'igor.ganapolsky@gmail.com';
|
|
21
23
|
const DEFAULT_FROM = 'onboarding@resend.dev';
|
|
22
|
-
const DEFAULT_REPLY_TO =
|
|
23
|
-
const DEFAULT_UNSUBSCRIBE_EMAIL =
|
|
24
|
+
const DEFAULT_REPLY_TO = DEFAULT_CONTACT_EMAIL;
|
|
25
|
+
const DEFAULT_UNSUBSCRIBE_EMAIL = DEFAULT_CONTACT_EMAIL;
|
|
24
26
|
const DEFAULT_BUSINESS_NAME = 'Max Smith KDP LLC';
|
|
25
27
|
// CAN-SPAM requires a physical mailing address. Override via THUMBGATE_BUSINESS_ADDRESS.
|
|
26
28
|
const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94114';
|
|
@@ -28,9 +30,23 @@ const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94
|
|
|
28
30
|
const BRAND_MARK_URL = 'https://thumbgate-production.up.railway.app/thumbgate-icon.png';
|
|
29
31
|
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
30
32
|
const TRIAL_LENGTH_DAYS = 7;
|
|
33
|
+
const SENDER_DNS_CACHE_MS = 10 * 60 * 1000;
|
|
34
|
+
// Bounded to RFC 5321 limits (local-part ≤ 64, domain ≤ 255) to prevent
|
|
35
|
+
// super-linear backtracking on malformed input (Sonar javascript:S5852).
|
|
36
|
+
const ANGLE_EMAIL_RE = /<([^<>@\s]{1,64}@[^<>@\s]{1,255})>/;
|
|
37
|
+
const BARE_EMAIL_RE = /([^\s<>@]{1,64}@[^\s<>@]{1,255})/;
|
|
38
|
+
const DKIM_PUBLIC_KEY_RE = /^p=/i;
|
|
39
|
+
const AMAZON_SES_MX_RE = /feedback-smtp\..*amazonaws\.com\.?$/i;
|
|
40
|
+
const AMAZON_SES_SPF_RE = /include:amazonses\.com/i;
|
|
41
|
+
const TRAILING_EMAIL_DOMAIN_PUNCTUATION = new Set(['>', ')', ',', '.', ';']);
|
|
42
|
+
const senderDnsCache = new Map();
|
|
31
43
|
|
|
32
44
|
function getApiKey() {
|
|
33
|
-
|
|
45
|
+
// Accept both `RESEND_API_KEY` (Railway default, matches provider docs) and
|
|
46
|
+
// the `THUMBGATE_`-prefixed variant that `scripts/billing.js` already honors.
|
|
47
|
+
// Keeping the two readers in sync prevents a silent "skipped: no_api_key"
|
|
48
|
+
// regression if an operator sets only the prefixed name.
|
|
49
|
+
return process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '';
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
function getFromAddress() {
|
|
@@ -41,6 +57,10 @@ function getReplyTo() {
|
|
|
41
57
|
return process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || DEFAULT_REPLY_TO;
|
|
42
58
|
}
|
|
43
59
|
|
|
60
|
+
function getSupportEmail() {
|
|
61
|
+
return process.env.THUMBGATE_SUPPORT_EMAIL || getReplyTo();
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
function getUnsubscribeEmail() {
|
|
45
65
|
return process.env.THUMBGATE_UNSUBSCRIBE_EMAIL || DEFAULT_UNSUBSCRIBE_EMAIL;
|
|
46
66
|
}
|
|
@@ -57,32 +77,193 @@ function isNonEmptyString(value) {
|
|
|
57
77
|
return typeof value === 'string' && value.trim().length > 0;
|
|
58
78
|
}
|
|
59
79
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
function isTrueEnv(value) {
|
|
81
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractEmailAddress(value) {
|
|
85
|
+
const text = String(value || '').trim();
|
|
86
|
+
const angleMatch = ANGLE_EMAIL_RE.exec(text);
|
|
87
|
+
if (angleMatch) return angleMatch[1];
|
|
88
|
+
const bareMatch = BARE_EMAIL_RE.exec(text);
|
|
89
|
+
return bareMatch ? bareMatch[1] : '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getEmailDomain(value) {
|
|
93
|
+
const email = extractEmailAddress(value);
|
|
94
|
+
const at = email.lastIndexOf('@');
|
|
95
|
+
if (at === -1) return '';
|
|
96
|
+
let domain = email.slice(at + 1).trim();
|
|
97
|
+
while (domain && TRAILING_EMAIL_DOMAIN_PUNCTUATION.has(domain.at(-1))) {
|
|
98
|
+
domain = domain.slice(0, -1);
|
|
99
|
+
}
|
|
100
|
+
return domain.toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function splitCsv(value) {
|
|
104
|
+
return String(value || '')
|
|
105
|
+
.split(',')
|
|
106
|
+
.map((part) => part.trim().toLowerCase())
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getVerifiedSenderDomains() {
|
|
111
|
+
return new Set(splitCsv(process.env.THUMBGATE_VERIFIED_SENDER_DOMAINS));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function flattenTxt(records) {
|
|
115
|
+
return (records || []).map((chunks) => Array.isArray(chunks) ? chunks.join('') : String(chunks));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getCachedSenderDnsReadiness(cacheKey) {
|
|
119
|
+
if (!cacheKey) return null;
|
|
120
|
+
const cached = senderDnsCache.get(cacheKey);
|
|
121
|
+
return cached && cached.expiresAt > Date.now() ? cached.ready : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setCachedSenderDnsReadiness(cacheKey, ready) {
|
|
125
|
+
if (cacheKey) senderDnsCache.set(cacheKey, { ready, expiresAt: Date.now() + SENDER_DNS_CACHE_MS });
|
|
126
|
+
return ready;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function readResendDnsRecords(domain, resolver) {
|
|
130
|
+
try {
|
|
131
|
+
const [dkimRecords, mxRecords, spfRecords] = await Promise.all([
|
|
132
|
+
resolver.resolveTxt(`resend._domainkey.${domain}`),
|
|
133
|
+
resolver.resolveMx(`send.${domain}`),
|
|
134
|
+
resolver.resolveTxt(`send.${domain}`),
|
|
135
|
+
]);
|
|
136
|
+
return { dkimRecords, mxRecords, spfRecords, errorCode: null };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
dkimRecords: [],
|
|
140
|
+
mxRecords: [],
|
|
141
|
+
spfRecords: [],
|
|
142
|
+
errorCode: error && error.code ? error.code : 'dns_lookup_failed',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recordsHaveResendDns({ dkimRecords, mxRecords, spfRecords }) {
|
|
148
|
+
const dkim = flattenTxt(dkimRecords);
|
|
149
|
+
const spf = flattenTxt(spfRecords);
|
|
150
|
+
return dkim.some((record) => DKIM_PUBLIC_KEY_RE.exec(record.trim()) !== null) &&
|
|
151
|
+
(mxRecords || []).some((record) => AMAZON_SES_MX_RE.exec(record.exchange || '') !== null) &&
|
|
152
|
+
spf.some((record) => AMAZON_SES_SPF_RE.exec(record) !== null);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function hasResendSenderDns(domain, { dnsResolver } = {}) {
|
|
156
|
+
if (!domain || domain === 'resend.dev') return true;
|
|
157
|
+
if (isTrueEnv(process.env.THUMBGATE_ALLOW_UNVERIFIED_SENDER)) return true;
|
|
158
|
+
if (getVerifiedSenderDomains().has(domain)) return true;
|
|
159
|
+
|
|
160
|
+
const cacheKey = dnsResolver ? null : domain;
|
|
161
|
+
const cached = getCachedSenderDnsReadiness(cacheKey);
|
|
162
|
+
if (cached !== null) return cached;
|
|
163
|
+
|
|
164
|
+
const records = await readResendDnsRecords(domain, dnsResolver || dns);
|
|
165
|
+
return setCachedSenderDnsReadiness(cacheKey, !records.errorCode && recordsHaveResendDns(records));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveSenderAddress(requestedFrom, { dnsResolver } = {}) {
|
|
169
|
+
const from = requestedFrom || getFromAddress();
|
|
170
|
+
const domain = getEmailDomain(from);
|
|
171
|
+
const ready = await hasResendSenderDns(domain, { dnsResolver });
|
|
172
|
+
if (ready) return { from, senderFallback: null };
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
from: DEFAULT_FROM,
|
|
176
|
+
senderFallback: {
|
|
177
|
+
requestedFrom: from,
|
|
178
|
+
fallbackFrom: DEFAULT_FROM,
|
|
179
|
+
domain,
|
|
180
|
+
reason: 'resend_dns_not_ready',
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateSendEmailInput({ to, subject, html, text }) {
|
|
65
186
|
if (!isNonEmptyString(to)) throw new Error('sendEmail: `to` is required');
|
|
66
187
|
if (!isNonEmptyString(subject)) throw new Error('sendEmail: `subject` is required');
|
|
67
188
|
if (!isNonEmptyString(html) && !isNonEmptyString(text)) {
|
|
68
189
|
throw new Error('sendEmail: at least one of `html` or `text` is required');
|
|
69
190
|
}
|
|
191
|
+
}
|
|
70
192
|
|
|
71
|
-
|
|
72
|
-
if (!
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
193
|
+
function warnSenderFallback(senderFallback) {
|
|
194
|
+
if (!senderFallback) return;
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn(
|
|
197
|
+
`[mailer] Sender domain ${senderFallback.domain || '(unknown)'} is missing Resend DNS; ` +
|
|
198
|
+
`falling back to ${senderFallback.fallbackFrom}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
77
201
|
|
|
202
|
+
function buildEmailPayload({ to, subject, html, text, replyTo, sender }) {
|
|
78
203
|
const payload = {
|
|
79
|
-
from: from
|
|
204
|
+
from: sender.from,
|
|
80
205
|
to: Array.isArray(to) ? to : [to],
|
|
81
206
|
subject,
|
|
82
207
|
reply_to: replyTo || getReplyTo(),
|
|
83
208
|
};
|
|
84
209
|
if (isNonEmptyString(html)) payload.html = html;
|
|
85
210
|
if (isNonEmptyString(text)) payload.text = text;
|
|
211
|
+
return payload;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseJsonOrNull(bodyText) {
|
|
215
|
+
if (!bodyText) return null;
|
|
216
|
+
try {
|
|
217
|
+
return JSON.parse(bodyText);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function postResendEmail({ fetcher, apiKey, payload }) {
|
|
225
|
+
const res = await fetcher(RESEND_ENDPOINT, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
'Content-Type': 'application/json',
|
|
229
|
+
Authorization: `Bearer ${apiKey}`,
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify(payload),
|
|
232
|
+
});
|
|
233
|
+
const bodyText = typeof res.text === 'function' ? await res.text() : '';
|
|
234
|
+
return { res, bodyText, bodyJson: parseJsonOrNull(bodyText) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function formatSendFailure(error) {
|
|
238
|
+
return error && error.message ? error.message : String(error);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildSendSuccess({ bodyJson, status, senderFallback }) {
|
|
242
|
+
return {
|
|
243
|
+
sent: true,
|
|
244
|
+
id: bodyJson?.id || null,
|
|
245
|
+
status,
|
|
246
|
+
...(senderFallback ? { senderFallback } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
|
|
252
|
+
* missing. Never throws on network errors; returns a structured result instead.
|
|
253
|
+
*/
|
|
254
|
+
async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dnsResolver } = {}) {
|
|
255
|
+
validateSendEmailInput({ to, subject, html, text });
|
|
256
|
+
|
|
257
|
+
const apiKey = getApiKey();
|
|
258
|
+
if (!apiKey) {
|
|
259
|
+
// eslint-disable-next-line no-console
|
|
260
|
+
console.warn('[mailer] RESEND_API_KEY not set — skipping send to', to);
|
|
261
|
+
return { sent: false, reason: 'no_api_key' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const sender = await resolveSenderAddress(from || getFromAddress(), { dnsResolver });
|
|
265
|
+
warnSenderFallback(sender.senderFallback);
|
|
266
|
+
const payload = buildEmailPayload({ to, subject, html, text, replyTo, sender });
|
|
86
267
|
|
|
87
268
|
const fetcher = fetchImpl || globalThis.fetch;
|
|
88
269
|
if (typeof fetcher !== 'function') {
|
|
@@ -92,32 +273,17 @@ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl } =
|
|
|
92
273
|
}
|
|
93
274
|
|
|
94
275
|
try {
|
|
95
|
-
const res = await fetcher
|
|
96
|
-
method: 'POST',
|
|
97
|
-
headers: {
|
|
98
|
-
'Content-Type': 'application/json',
|
|
99
|
-
Authorization: `Bearer ${apiKey}`,
|
|
100
|
-
},
|
|
101
|
-
body: JSON.stringify(payload),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const bodyText = typeof res.text === 'function' ? await res.text() : '';
|
|
105
|
-
let bodyJson = null;
|
|
106
|
-
if (bodyText) {
|
|
107
|
-
try { bodyJson = JSON.parse(bodyText); } catch (_) { /* leave as text */ }
|
|
108
|
-
}
|
|
109
|
-
|
|
276
|
+
const { res, bodyText, bodyJson } = await postResendEmail({ fetcher, apiKey, payload });
|
|
110
277
|
if (!res.ok) {
|
|
111
278
|
// eslint-disable-next-line no-console
|
|
112
279
|
console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
|
|
113
280
|
return { sent: false, reason: 'api_error', status: res.status, body: bodyJson || bodyText };
|
|
114
281
|
}
|
|
115
|
-
|
|
116
|
-
return { sent: true, id: bodyJson && bodyJson.id ? bodyJson.id : null, status: res.status };
|
|
282
|
+
return buildSendSuccess({ bodyJson, status: res.status, senderFallback: sender.senderFallback });
|
|
117
283
|
} catch (err) {
|
|
118
284
|
// eslint-disable-next-line no-console
|
|
119
|
-
console.warn('[mailer] send failed:', err
|
|
120
|
-
return { sent: false, reason: 'exception', error:
|
|
285
|
+
console.warn('[mailer] send failed:', formatSendFailure(err));
|
|
286
|
+
return { sent: false, reason: 'exception', error: formatSendFailure(err) };
|
|
121
287
|
}
|
|
122
288
|
}
|
|
123
289
|
|
|
@@ -173,6 +339,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
173
339
|
const exampleFeedback =
|
|
174
340
|
'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
|
|
175
341
|
const proofUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
|
|
342
|
+
const supportEmail = getSupportEmail();
|
|
176
343
|
const unsubscribeEmail = getUnsubscribeEmail();
|
|
177
344
|
const businessName = getBusinessName();
|
|
178
345
|
const businessAddress = getBusinessAddress();
|
|
@@ -205,7 +372,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
205
372
|
'',
|
|
206
373
|
postscript,
|
|
207
374
|
'',
|
|
208
|
-
`Questions? Just reply to this email or write ${
|
|
375
|
+
`Questions? Just reply to this email or write ${supportEmail}.`,
|
|
209
376
|
'',
|
|
210
377
|
'— Igor, founder of ThumbGate',
|
|
211
378
|
'',
|
|
@@ -222,6 +389,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
222
389
|
const safeDescription = escapeHtml(description);
|
|
223
390
|
const safeExample = escapeHtml(exampleFeedback);
|
|
224
391
|
const safePostscript = escapeHtml(postscript);
|
|
392
|
+
const safeSupportEmail = escapeHtml(supportEmail);
|
|
225
393
|
const safeBusinessName = escapeHtml(businessName);
|
|
226
394
|
const safeBusinessAddress = escapeHtml(businessAddress);
|
|
227
395
|
const safeUnsubscribeEmail = escapeHtml(unsubscribeEmail);
|
|
@@ -281,7 +449,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
281
449
|
<p style="margin:0 0 4px;font-size:14px;line-height:1.6;color:#17212b;">— Igor, founder of ThumbGate</p>
|
|
282
450
|
<p style="margin:0;font-size:13px;line-height:1.55;color:#526273;">
|
|
283
451
|
Questions? Just reply to this email or write
|
|
284
|
-
<a href="mailto:${
|
|
452
|
+
<a href="mailto:${safeSupportEmail}" style="color:#087a91;">${safeSupportEmail}</a>.
|
|
285
453
|
</p>
|
|
286
454
|
</td>
|
|
287
455
|
</tr>
|
|
@@ -317,7 +485,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
317
485
|
* Never throws on send failures (beyond input validation); the Stripe webhook
|
|
318
486
|
* must keep working even if email breaks.
|
|
319
487
|
*/
|
|
320
|
-
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl } = {}) {
|
|
488
|
+
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl, dnsResolver } = {}) {
|
|
321
489
|
if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
|
|
322
490
|
if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
|
|
323
491
|
|
|
@@ -327,17 +495,19 @@ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName,
|
|
|
327
495
|
? `${name}, your ThumbGate Pro key is inside`
|
|
328
496
|
: 'Your ThumbGate Pro key is inside';
|
|
329
497
|
|
|
330
|
-
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl });
|
|
498
|
+
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl, dnsResolver });
|
|
331
499
|
}
|
|
332
500
|
|
|
333
501
|
module.exports = {
|
|
334
502
|
sendEmail,
|
|
335
503
|
sendTrialWelcomeEmail,
|
|
336
504
|
renderTrialWelcomeBodies,
|
|
505
|
+
_resolveSenderAddress: resolveSenderAddress,
|
|
506
|
+
_hasResendSenderDns: hasResendSenderDns,
|
|
337
507
|
_constants: {
|
|
338
508
|
PRODUCT_NAME,
|
|
339
509
|
DASHBOARD_URL,
|
|
340
|
-
|
|
510
|
+
DEFAULT_CONTACT_EMAIL,
|
|
341
511
|
DEFAULT_FROM,
|
|
342
512
|
DEFAULT_REPLY_TO,
|
|
343
513
|
DEFAULT_UNSUBSCRIBE_EMAIL,
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const { getRuntimeDir, resolveProjectDir } = require('./feedback-paths');
|
|
9
|
+
|
|
10
|
+
const FIXED_GH_BINARIES = [
|
|
11
|
+
'/usr/bin/gh',
|
|
12
|
+
'/usr/local/bin/gh',
|
|
13
|
+
'/opt/homebrew/bin/gh',
|
|
14
|
+
];
|
|
15
|
+
const FIXED_GIT_BINARIES = [
|
|
16
|
+
'/usr/bin/git',
|
|
17
|
+
'/usr/local/bin/git',
|
|
18
|
+
'/opt/homebrew/bin/git',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const CONTEXT_CACHE_MAX_AGE_MS = 120000;
|
|
22
|
+
|
|
23
|
+
function contextCachePath(options = {}) {
|
|
24
|
+
return path.join(getRuntimeDir(options), 'statusline-context.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readContextCache(options = {}) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(contextCachePath(options), 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeContextCache(payload, options = {}) {
|
|
36
|
+
const targetPath = contextCachePath(options);
|
|
37
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
38
|
+
fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2) + '\n');
|
|
39
|
+
return targetPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isFreshCache(cache, now = Date.now()) {
|
|
43
|
+
if (!cache || !cache.updatedAt) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const updatedAt = Date.parse(cache.updatedAt);
|
|
48
|
+
if (!Number.isFinite(updatedAt)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (now - updatedAt) <= CONTEXT_CACHE_MAX_AGE_MS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveGhBinary(accessSync = fs.accessSync) {
|
|
56
|
+
for (const candidate of FIXED_GH_BINARIES) {
|
|
57
|
+
try {
|
|
58
|
+
accessSync(candidate, fs.constants.X_OK);
|
|
59
|
+
return candidate;
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveGitBinary(options = {}) {
|
|
68
|
+
const env = options.env || process.env;
|
|
69
|
+
const configuredBinary = String(env.THUMBGATE_GIT_BIN || '').trim();
|
|
70
|
+
const candidates = configuredBinary
|
|
71
|
+
? [configuredBinary, ...FIXED_GIT_BINARIES]
|
|
72
|
+
: FIXED_GIT_BINARIES;
|
|
73
|
+
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
try {
|
|
76
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
77
|
+
return candidate;
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runCommand(command, args, options = {}) {
|
|
87
|
+
const result = spawnSync(command, args, {
|
|
88
|
+
cwd: options.cwd,
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
timeout: options.timeoutMs || 400,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return String(result.stdout || '').trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getBranchName(projectDir) {
|
|
102
|
+
if (!projectDir) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const gitBinary = resolveGitBinary();
|
|
107
|
+
if (!gitBinary) {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return runCommand(gitBinary, ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectDir, timeoutMs: 300 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function inferWorkItemLabel(branchName = '') {
|
|
115
|
+
const normalized = String(branchName || '').trim();
|
|
116
|
+
if (!normalized) {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const explicitMatch = /(?:^|[/-])(AB#\d+)(?:$|[/-])/i.exec(normalized);
|
|
121
|
+
if (explicitMatch) {
|
|
122
|
+
return explicitMatch[1].toUpperCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const numericMatch = /(?:^|[/-])(\d{4,})(?:$|[/-])/.exec(normalized);
|
|
126
|
+
if (!numericMatch) {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `AB#${numericMatch[1]}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getPrNumber(projectDir, options = {}) {
|
|
134
|
+
if (!projectDir) {
|
|
135
|
+
return '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ghBinary = options.ghBinary || resolveGhBinary(options.accessSync);
|
|
139
|
+
if (!ghBinary) {
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const prNumber = runCommand(
|
|
144
|
+
ghBinary,
|
|
145
|
+
['pr', 'view', '--json', 'number', '--jq', '.number'],
|
|
146
|
+
{ cwd: projectDir, timeoutMs: options.timeoutMs || 1000 }
|
|
147
|
+
);
|
|
148
|
+
return /^\d+$/.test(prNumber) ? prNumber : '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getStatuslineContext(options = {}) {
|
|
152
|
+
const env = options.env || process.env;
|
|
153
|
+
if (env._TEST_THUMBGATE_STATUSLINE_CONTEXT_JSON) {
|
|
154
|
+
return JSON.parse(env._TEST_THUMBGATE_STATUSLINE_CONTEXT_JSON);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const projectDir = resolveProjectDir({ env, cwd: options.cwd || process.cwd() });
|
|
158
|
+
const cache = readContextCache({ env, home: options.homeDir });
|
|
159
|
+
const branchName = (env.THUMBGATE_STATUSLINE_BRANCH || '').trim() || getBranchName(projectDir);
|
|
160
|
+
const workItemLabel = (env.THUMBGATE_STATUSLINE_WORK_ITEM || '').trim() || inferWorkItemLabel(branchName);
|
|
161
|
+
|
|
162
|
+
let prNumber = (env.THUMBGATE_STATUSLINE_PR_NUMBER || '').trim();
|
|
163
|
+
if (!prNumber && cache && cache.projectDir === projectDir && cache.branchName === branchName && isFreshCache(cache)) {
|
|
164
|
+
prNumber = String(cache.prNumber || '').trim();
|
|
165
|
+
}
|
|
166
|
+
if (!prNumber) {
|
|
167
|
+
prNumber = getPrNumber(projectDir, options);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const payload = {
|
|
171
|
+
branchName,
|
|
172
|
+
workItemLabel,
|
|
173
|
+
prNumber,
|
|
174
|
+
prLabel: prNumber ? `PR #${prNumber}` : '',
|
|
175
|
+
projectDir,
|
|
176
|
+
updatedAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
writeContextCache(payload, { env, home: options.homeDir });
|
|
179
|
+
return payload;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isCliInvocation(argv = process.argv) {
|
|
183
|
+
const invokedPath = argv[1];
|
|
184
|
+
return invokedPath ? path.resolve(invokedPath) === __filename : false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (isCliInvocation()) {
|
|
188
|
+
try {
|
|
189
|
+
process.stdout.write(JSON.stringify(getStatuslineContext()));
|
|
190
|
+
} catch {
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
CONTEXT_CACHE_MAX_AGE_MS,
|
|
197
|
+
contextCachePath,
|
|
198
|
+
getBranchName,
|
|
199
|
+
getPrNumber,
|
|
200
|
+
getStatuslineContext,
|
|
201
|
+
inferWorkItemLabel,
|
|
202
|
+
isFreshCache,
|
|
203
|
+
readContextCache,
|
|
204
|
+
resolveGhBinary,
|
|
205
|
+
resolveGitBinary,
|
|
206
|
+
writeContextCache,
|
|
207
|
+
};
|