thumbgate 1.28.0 → 1.28.2
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/plugin.json +2 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +96 -122
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +54 -34
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +100 -77
- package/config/gates/default.json +2 -2
- package/config/github-about.json +2 -5
- package/config/mcp-allowlists.json +5 -0
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +19 -8
- package/public/chatgpt-app.html +2 -2
- package/public/codex-enterprise.html +1 -1
- package/public/codex-plugin.html +1 -1
- package/public/diagnostic.html +23 -1
- package/public/guide.html +1 -1
- package/public/index.html +172 -201
- package/public/numbers.html +2 -2
- package/public/partner-intake.html +198 -0
- package/public/pricing.html +51 -14
- package/scripts/auto-wire-hooks.js +58 -2
- package/scripts/billing.js +815 -137
- package/scripts/checkout-attribution-reference.js +85 -0
- package/scripts/claude-feedback-sync.js +23 -6
- package/scripts/cli-feedback.js +20 -10
- package/scripts/feedback-history-distiller.js +385 -0
- package/scripts/feedback-loop.js +290 -1
- package/scripts/feedback-quality.js +25 -26
- package/scripts/feedback-sanitizer.js +151 -3
- package/scripts/gates-engine.js +128 -49
- package/scripts/hosted-config.js +9 -2
- package/scripts/mailer/resend-mailer.js +11 -5
- package/scripts/secret-scanner.js +363 -68
- package/scripts/self-protection.js +21 -36
- package/src/api/server.js +121 -12
- package/public/assets/brand/github-social-preview.png +0 -0
package/scripts/gates-engine.js
CHANGED
|
@@ -131,42 +131,40 @@ const DOWNLOAD_EXEC_CHAIN_PATTERN = /\b(?:curl|wget)\b[\s\S]{0,400}(?:\|\s*(?:ba
|
|
|
131
131
|
const DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN = /\b(?:rm\s+-rf|chmod\s+(?:\+x|777)|chown\b|sudo\b|dd\s+if=|mkfs|git\s+reset\s+--hard|git\s+clean\s+-f[a-z]*|kubectl\s+(?:apply|delete)|terraform\s+(?:apply|destroy)|railway\s+(?:deploy|up)|gcloud\s+(?:run\s+deploy|app\s+deploy)|vercel\s+--prod|firebase\s+deploy)\b/i;
|
|
132
132
|
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
|
-
// Enforcement posture (CEO decision 2026-06-04): warn-by-default.
|
|
135
|
-
// The firewall ALWAYS fires and logs every decision, but most gates WARN rather
|
|
136
|
-
// than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
|
|
137
|
-
// - secret exfiltration (handled on its own deny path; never downgraded)
|
|
138
|
-
// - security-vulnerability / supply-chain denies (own deny path; not downgraded)
|
|
139
|
-
// - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
|
|
140
|
-
// fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
|
|
141
|
-
// Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
|
|
142
|
-
// approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
|
|
143
|
-
// Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
144
134
|
// Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
135
|
+
// Unconditional floors run before posture conversion: secret exfiltration, deny results
|
|
136
|
+
// from the security scanner, and the four canonical self-protection gates. Arbitrary
|
|
137
|
+
// destructive commands do not get a regex-based "catastrophic" floor; wrappers and
|
|
138
|
+
// obfuscation make that boundary misleading. Every other deny/approve becomes a warning
|
|
139
|
+
// unless the operator explicitly enables THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
140
|
+
const SELF_PROTECT_HARD_FLOOR_GATE_IDS = new Set([
|
|
141
|
+
'self-protect-config',
|
|
142
|
+
'self-protect-kill',
|
|
143
|
+
'self-protect-env-override',
|
|
144
|
+
'self-protect-hooks-disable',
|
|
145
|
+
]);
|
|
146
|
+
const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
|
|
147
|
+
'secret-exfiltration',
|
|
148
|
+
'security-vuln-scan',
|
|
149
|
+
'slopsquat-guard',
|
|
150
|
+
...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
|
|
151
|
+
]);
|
|
152
|
+
const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
|
|
153
|
+
const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
|
|
154
|
+
const SELF_PROTECT_CONFIG_COMMAND_PATTERN = /(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json\b|\.thumbgate\/config\.json\b|thumbgate\.json\b)/i;
|
|
155
|
+
const SELF_PROTECT_HOOK_COMMAND_PATTERN = /(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^\s'";|]+\.(?:js|sh))\b/i;
|
|
156
|
+
const SHELL_FILE_MUTATION_PATTERN = /\b(?:sed\s+-i|perl\s+-pi|python\d*\s+-c|node\s+-e|ruby\s+-e|tee|truncate|rm|mv|cp|install|patch|jq)\b|(?:^|[\s;&|])>{1,2}\s*\S/i;
|
|
157
|
+
|
|
156
158
|
function isSelfProtectGate(gateId) {
|
|
157
|
-
return
|
|
159
|
+
return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
function applyEnforcementPosture(result) {
|
|
161
163
|
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
164
|
+
// Defensive backstop: hard-floor results must never be posture-downgraded.
|
|
165
|
+
if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) return result;
|
|
162
166
|
// Full hard enforcement opt-in: keep every deny.
|
|
163
167
|
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
|
|
164
|
-
// Self-protection binds regardless of posture: a warning is worthless once the guardrail is
|
|
165
|
-
// gone. Owner escape: THUMBGATE_SELF_PROTECT_OVERRIDE=1 (break-glass covers .claude/settings*
|
|
166
|
-
// but not this surface, so it needs its own escape — self-lockout, 2026-07-07).
|
|
167
|
-
if (isSelfProtectGate(result.gate) && process.env.THUMBGATE_SELF_PROTECT_OVERRIDE !== '1') {
|
|
168
|
-
return result;
|
|
169
|
-
}
|
|
170
168
|
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
171
169
|
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
|
|
172
170
|
// Warn-by-default: the gate still fired and is recorded; the action is allowed through
|
|
@@ -175,7 +173,7 @@ function applyEnforcementPosture(result) {
|
|
|
175
173
|
...result,
|
|
176
174
|
decision: 'warn',
|
|
177
175
|
warnByDefault: true,
|
|
178
|
-
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block
|
|
176
|
+
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block other flagged actions.`,
|
|
179
177
|
};
|
|
180
178
|
}
|
|
181
179
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
@@ -1710,6 +1708,10 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1710
1708
|
const governanceState = loadGovernanceState();
|
|
1711
1709
|
const constraints = loadConstraints();
|
|
1712
1710
|
|
|
1711
|
+
if (isSelfProtectGate(gate.id) && hasActiveProtectedApproval(governanceState, affectedFiles)) {
|
|
1712
|
+
return { matched: false, matchText, affectedFiles };
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1713
1715
|
if (gate.id === 'on-demand-freeze-mode' || (gate.when && gate.when.constraints && gate.when.constraints.freeze_mode)) {
|
|
1714
1716
|
let freezePaths = [];
|
|
1715
1717
|
if (process.env.THUMBGATE_FREEZE_PATHS) {
|
|
@@ -1817,6 +1819,78 @@ function matchesGate(gate, toolName, toolInput) {
|
|
|
1817
1819
|
return matchGate(gate, toolName, toolInput).matched;
|
|
1818
1820
|
}
|
|
1819
1821
|
|
|
1822
|
+
function hasActiveProtectedApproval(governanceState, affectedFiles) {
|
|
1823
|
+
if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return false;
|
|
1824
|
+
const approvals = Array.isArray(governanceState && governanceState.protectedApprovals)
|
|
1825
|
+
? governanceState.protectedApprovals
|
|
1826
|
+
: [];
|
|
1827
|
+
return affectedFiles.every((filePath) => approvals.some((entry) => {
|
|
1828
|
+
return matchesAnyGlob(filePath, sanitizeGlobList(entry && entry.pathGlobs));
|
|
1829
|
+
}));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
1833
|
+
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1834
|
+
const affectedFiles = affected.files;
|
|
1835
|
+
if (hasActiveProtectedApproval(loadGovernanceState(), affectedFiles)) return null;
|
|
1836
|
+
|
|
1837
|
+
const command = String(toolInput.command || '');
|
|
1838
|
+
let matchText = command;
|
|
1839
|
+
if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
|
|
1840
|
+
const targetPattern = gate.id === 'self-protect-config'
|
|
1841
|
+
? SELF_PROTECT_CONFIG_TARGET_PATTERN
|
|
1842
|
+
: SELF_PROTECT_HOOK_TARGET_PATTERN;
|
|
1843
|
+
if (EDIT_LIKE_TOOLS.has(toolName)) {
|
|
1844
|
+
matchText = affectedFiles.join(' ');
|
|
1845
|
+
if (!targetPattern.test(matchText)) return null;
|
|
1846
|
+
} else if (toolName === 'Bash') {
|
|
1847
|
+
const commandTargetPattern = gate.id === 'self-protect-config'
|
|
1848
|
+
? SELF_PROTECT_CONFIG_COMMAND_PATTERN
|
|
1849
|
+
: SELF_PROTECT_HOOK_COMMAND_PATTERN;
|
|
1850
|
+
if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
|
|
1851
|
+
} else {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
} else {
|
|
1855
|
+
if (!Array.isArray(gate.toolNames) || !gate.toolNames.includes(toolName)) return null;
|
|
1856
|
+
if (!matchText || !gate.pattern) return null;
|
|
1857
|
+
try {
|
|
1858
|
+
if (!new RegExp(gate.pattern).test(matchText)) return null;
|
|
1859
|
+
} catch {
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return {
|
|
1865
|
+
matched: true,
|
|
1866
|
+
matchText,
|
|
1867
|
+
affectedFiles,
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function evaluateSelfProtectHardFloor(input = {}) {
|
|
1872
|
+
const toolName = input.tool_name || input.toolName || '';
|
|
1873
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
1874
|
+
const config = loadGatesConfig(DEFAULT_CONFIG_PATH);
|
|
1875
|
+
|
|
1876
|
+
for (const gate of config.gates) {
|
|
1877
|
+
if (!isSelfProtectGate(gate.id)) continue;
|
|
1878
|
+
const matchDetails = matchSelfProtectHardFloor(gate, toolName, toolInput);
|
|
1879
|
+
if (!matchDetails) continue;
|
|
1880
|
+
|
|
1881
|
+
const result = {
|
|
1882
|
+
decision: 'deny',
|
|
1883
|
+
gate: gate.id,
|
|
1884
|
+
message: buildGateMessage(gate, matchDetails),
|
|
1885
|
+
severity: gate.severity,
|
|
1886
|
+
reasoning: buildReasoning(gate, toolName, toolInput, matchDetails),
|
|
1887
|
+
};
|
|
1888
|
+
return recordStructuralGateBlock(toolName, toolInput, result);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
return null;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1820
1894
|
function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
1821
1895
|
if (toolName !== 'Bash') return false;
|
|
1822
1896
|
const command = String(toolInput.command || '').trim();
|
|
@@ -2643,6 +2717,26 @@ function evaluateSecretGuard(input = {}) {
|
|
|
2643
2717
|
return result;
|
|
2644
2718
|
}
|
|
2645
2719
|
|
|
2720
|
+
function evaluateUnconditionalHardFloor(input = {}) {
|
|
2721
|
+
const secretGuard = evaluateSecretGuard(input);
|
|
2722
|
+
if (secretGuard) return { hardFloor: secretGuard, securityScan: null };
|
|
2723
|
+
|
|
2724
|
+
const securityScan = evaluateSecurityScan(input);
|
|
2725
|
+
if (securityScan && securityScan.decision === 'deny') {
|
|
2726
|
+
return { hardFloor: securityScan, securityScan };
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
return {
|
|
2730
|
+
hardFloor: evaluateSelfProtectHardFloor(input),
|
|
2731
|
+
securityScan,
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
function runHardFloor(input) {
|
|
2736
|
+
const { hardFloor } = evaluateUnconditionalHardFloor(input);
|
|
2737
|
+
return hardFloor ? formatOutput(hardFloor) : null;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2646
2740
|
// ---------------------------------------------------------------------------
|
|
2647
2741
|
function isApprovalGatesEnabled() {
|
|
2648
2742
|
return process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
@@ -2996,16 +3090,8 @@ function mergeContextStrings(...ctxs) {
|
|
|
2996
3090
|
}
|
|
2997
3091
|
|
|
2998
3092
|
async function runAsync(input) {
|
|
2999
|
-
const
|
|
3000
|
-
if (
|
|
3001
|
-
return formatOutput(secretGuard);
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
// Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
|
|
3005
|
-
const securityScan = evaluateSecurityScan(input);
|
|
3006
|
-
if (securityScan && securityScan.decision === 'deny') {
|
|
3007
|
-
return formatOutput(securityScan);
|
|
3008
|
-
}
|
|
3093
|
+
const { hardFloor, securityScan } = evaluateUnconditionalHardFloor(input);
|
|
3094
|
+
if (hardFloor) return formatOutput(hardFloor);
|
|
3009
3095
|
|
|
3010
3096
|
const toolName = input.tool_name || '';
|
|
3011
3097
|
const toolInput = input.tool_input || {};
|
|
@@ -3043,16 +3129,8 @@ async function runAsync(input) {
|
|
|
3043
3129
|
}
|
|
3044
3130
|
|
|
3045
3131
|
function run(input) {
|
|
3046
|
-
const
|
|
3047
|
-
if (
|
|
3048
|
-
return formatOutput(secretGuard);
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
// Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
|
|
3052
|
-
const securityScan = evaluateSecurityScan(input);
|
|
3053
|
-
if (securityScan && securityScan.decision === 'deny') {
|
|
3054
|
-
return formatOutput(securityScan);
|
|
3055
|
-
}
|
|
3132
|
+
const { hardFloor, securityScan } = evaluateUnconditionalHardFloor(input);
|
|
3133
|
+
if (hardFloor) return formatOutput(hardFloor);
|
|
3056
3134
|
|
|
3057
3135
|
const toolName = input.tool_name || '';
|
|
3058
3136
|
const toolInput = input.tool_input || {};
|
|
@@ -3379,6 +3457,7 @@ module.exports = {
|
|
|
3379
3457
|
computeExecutableHash,
|
|
3380
3458
|
formatOutput,
|
|
3381
3459
|
isApprovalGatesEnabled,
|
|
3460
|
+
runHardFloor,
|
|
3382
3461
|
run,
|
|
3383
3462
|
runAsync,
|
|
3384
3463
|
trackAction,
|
package/scripts/hosted-config.js
CHANGED
|
@@ -13,6 +13,9 @@ const DEFAULT_PRO_PRICE_DOLLARS = PRO_MONTHLY_PRICE_DOLLARS;
|
|
|
13
13
|
const DEFAULT_PRO_PRICE_LABEL = PRO_PRICE_LABEL;
|
|
14
14
|
const DEFAULT_SPRINT_DIAGNOSTIC_PRICE_DOLLARS = 499;
|
|
15
15
|
const DEFAULT_WORKFLOW_SPRINT_PRICE_DOLLARS = 1500;
|
|
16
|
+
// Keep fallbacks price-aligned. Sprint must NOT share the diagnostic Stripe slug.
|
|
17
|
+
const DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/9B69ATbmI4r4aK5eOD3sI3k';
|
|
18
|
+
const DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL = 'https://www.paypal.com/ncp/payment/LTQFR7P9AR3QG';
|
|
16
19
|
const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i;
|
|
17
20
|
|
|
18
21
|
function normalizeOrigin(value) {
|
|
@@ -127,8 +130,10 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
|
|
|
127
130
|
const gaMeasurementId = normalizeTrackingId(env.THUMBGATE_GA_MEASUREMENT_ID, GA_MEASUREMENT_ID_PATTERN);
|
|
128
131
|
const posthogApiKey = env.POSTHOG_API_KEY || '';
|
|
129
132
|
const googleSiteVerification = normalizeTrackingId(env.THUMBGATE_GOOGLE_SITE_VERIFICATION);
|
|
130
|
-
const sprintDiagnosticCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_SPRINT_DIAGNOSTIC_CHECKOUT_URL)
|
|
131
|
-
|
|
133
|
+
const sprintDiagnosticCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_SPRINT_DIAGNOSTIC_CHECKOUT_URL)
|
|
134
|
+
|| DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL;
|
|
135
|
+
const workflowSprintCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_WORKFLOW_SPRINT_CHECKOUT_URL)
|
|
136
|
+
|| DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL;
|
|
132
137
|
|
|
133
138
|
return {
|
|
134
139
|
appOrigin,
|
|
@@ -159,6 +164,8 @@ module.exports = {
|
|
|
159
164
|
DEFAULT_PRO_PRICE_LABEL,
|
|
160
165
|
DEFAULT_SPRINT_DIAGNOSTIC_PRICE_DOLLARS,
|
|
161
166
|
DEFAULT_WORKFLOW_SPRINT_PRICE_DOLLARS,
|
|
167
|
+
DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL,
|
|
168
|
+
DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL,
|
|
162
169
|
GA_MEASUREMENT_ID_PATTERN,
|
|
163
170
|
normalizeAbsoluteUrl,
|
|
164
171
|
normalizeOrigin,
|
|
@@ -226,12 +226,13 @@ function parseJsonOrNull(bodyText) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
async function postResendEmail({ fetcher, apiKey, payload }) {
|
|
229
|
+
async function postResendEmail({ fetcher, apiKey, payload, idempotencyKey }) {
|
|
230
230
|
const res = await fetcher(RESEND_ENDPOINT, {
|
|
231
231
|
method: 'POST',
|
|
232
232
|
headers: {
|
|
233
233
|
'Content-Type': 'application/json',
|
|
234
234
|
Authorization: `Bearer ${apiKey}`,
|
|
235
|
+
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
235
236
|
},
|
|
236
237
|
body: JSON.stringify(payload),
|
|
237
238
|
});
|
|
@@ -256,7 +257,7 @@ function buildSendSuccess({ bodyJson, status, senderFallback }) {
|
|
|
256
257
|
* Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
|
|
257
258
|
* missing. Never throws on network errors; returns a structured result instead.
|
|
258
259
|
*/
|
|
259
|
-
async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dnsResolver } = {}) {
|
|
260
|
+
async function sendEmail({ to, subject, html, text, from, replyTo, idempotencyKey, fetchImpl, dnsResolver } = {}) {
|
|
260
261
|
validateSendEmailInput({ to, subject, html, text });
|
|
261
262
|
|
|
262
263
|
const apiKey = getApiKey();
|
|
@@ -278,7 +279,12 @@ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dn
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
try {
|
|
281
|
-
const { res, bodyText, bodyJson } = await postResendEmail({
|
|
282
|
+
const { res, bodyText, bodyJson } = await postResendEmail({
|
|
283
|
+
fetcher,
|
|
284
|
+
apiKey,
|
|
285
|
+
payload,
|
|
286
|
+
idempotencyKey: isNonEmptyString(idempotencyKey) ? idempotencyKey.slice(0, 256) : null,
|
|
287
|
+
});
|
|
282
288
|
if (!res.ok) {
|
|
283
289
|
// eslint-disable-next-line no-console
|
|
284
290
|
console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
|
|
@@ -490,7 +496,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
490
496
|
* Never throws on send failures (beyond input validation); the Stripe webhook
|
|
491
497
|
* must keep working even if email breaks.
|
|
492
498
|
*/
|
|
493
|
-
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl, dnsResolver } = {}) {
|
|
499
|
+
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, idempotencyKey, fetchImpl, dnsResolver } = {}) {
|
|
494
500
|
if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
|
|
495
501
|
if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
|
|
496
502
|
|
|
@@ -500,7 +506,7 @@ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName,
|
|
|
500
506
|
? `${name}, your ThumbGate Pro key is inside`
|
|
501
507
|
: 'Your ThumbGate Pro key is inside';
|
|
502
508
|
|
|
503
|
-
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl, dnsResolver });
|
|
509
|
+
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), idempotencyKey, fetchImpl, dnsResolver });
|
|
504
510
|
}
|
|
505
511
|
|
|
506
512
|
function renderNewsletterWelcomeBodies() {
|