@teamscala/service-boot 0.23.389 → 0.23.391
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/package.json
CHANGED
|
@@ -3,20 +3,26 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* from-address
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Hot snapshot of `email-defaults.default_sender` (outbound transactional
|
|
7
|
+
* from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
|
|
8
|
+
* Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
|
|
9
|
+
* safety ceiling for an upstream regression that drops notifications.
|
|
10
10
|
*/
|
|
11
|
+
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
11
12
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
12
13
|
import * as v from "valibot";
|
|
13
14
|
|
|
14
15
|
const FALLBACK_SENDER = "noreply@gateway.scala.business";
|
|
15
|
-
|
|
16
|
+
const defaultSenderCache = createNotifyCache<string>({
|
|
17
|
+
name: "service-boot:default-email-sender",
|
|
18
|
+
load: async () => {
|
|
19
|
+
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
+
return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
+
},
|
|
22
|
+
invalidateOn: ["registry_config_changed"],
|
|
23
|
+
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
+
});
|
|
16
25
|
|
|
17
26
|
export async function getDefaultEmailSender(): Promise<string> {
|
|
18
|
-
|
|
19
|
-
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
-
cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
-
return cached;
|
|
27
|
+
return defaultSenderCache.get();
|
|
22
28
|
}
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the source of truth, not the literal).
|
|
6
|
+
* Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
|
|
7
|
+
* window, dedup'd across the 3 fn-routers that mint them). Replaces the
|
|
8
|
+
* former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
|
|
9
|
+
* `registry_config_changed`.
|
|
11
10
|
*/
|
|
11
|
+
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
12
12
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
13
13
|
import * as v from "valibot";
|
|
14
14
|
|
|
15
15
|
const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
-
|
|
16
|
+
const emailLinkTtlCache = createNotifyCache<number>({
|
|
17
|
+
name: "service-boot:email-link-ttl",
|
|
18
|
+
load: async () => {
|
|
19
|
+
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
20
|
+
return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
21
|
+
},
|
|
22
|
+
invalidateOn: ["registry_config_changed"],
|
|
23
|
+
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
+
});
|
|
17
25
|
|
|
18
26
|
export async function getEmailLinkTtlMs(): Promise<number> {
|
|
19
|
-
|
|
20
|
-
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
21
|
-
cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
22
|
-
return cached;
|
|
27
|
+
return emailLinkTtlCache.get();
|
|
23
28
|
}
|
|
@@ -25,10 +25,11 @@ import * as v from "valibot";
|
|
|
25
25
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
26
26
|
import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
|
|
27
27
|
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
|
|
28
|
+
import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
|
|
28
29
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
29
30
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
31
|
+
import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
|
|
30
32
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
31
|
-
import { getDefaultEmailSender } from "./email-defaults.ts";
|
|
32
33
|
|
|
33
34
|
interface Context {
|
|
34
35
|
prisma: any;
|
|
@@ -106,9 +107,9 @@ function bandForScore(
|
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Best-effort branded Authority Index Report email. Self-contained: loads its
|
|
109
|
-
* own Resend secret, swallows every error
|
|
110
|
-
*
|
|
111
|
-
*
|
|
110
|
+
* own Resend secret, swallows every error. Sender identity + brand tokens
|
|
111
|
+
* come from resolveEmailSender (per-org email_sender row). On Resend refusal
|
|
112
|
+
* the call falls back to the platform sender and logs the reason.
|
|
112
113
|
*/
|
|
113
114
|
async function sendAuthorityReportEmail(opts: {
|
|
114
115
|
to: string;
|
|
@@ -117,6 +118,9 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
117
118
|
bandLabel: string;
|
|
118
119
|
categoryScores: Record<string, number>;
|
|
119
120
|
ctaUrl?: string;
|
|
121
|
+
organisationId?: string | null;
|
|
122
|
+
brand?: import("@teamscala/email/identity/resolve-email-sender").OrgBrandTokens;
|
|
123
|
+
prisma: any;
|
|
120
124
|
}): Promise<void> {
|
|
121
125
|
const log = getAppLogger();
|
|
122
126
|
const rawSecret = await loadRegistryConfig(
|
|
@@ -132,17 +136,26 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
132
136
|
return;
|
|
133
137
|
}
|
|
134
138
|
initResendClient(decrypted.token);
|
|
135
|
-
|
|
139
|
+
|
|
140
|
+
const sender = await resolveEmailSender(opts.organisationId ?? null);
|
|
141
|
+
const primary = sender.brand.primaryColor;
|
|
142
|
+
const muted = "#6b7280"; // structural neutral for body labels
|
|
143
|
+
const logoUrl = sender.brand.logoUrl;
|
|
144
|
+
const logoHtml =
|
|
145
|
+
logoUrl && /^https?:\/\//.test(logoUrl)
|
|
146
|
+
? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
|
|
147
|
+
: "";
|
|
148
|
+
|
|
136
149
|
// Subject + body from the `authority-index-report` email_template row, not
|
|
137
|
-
// literals here (email-template-registry-type).
|
|
138
|
-
//
|
|
139
|
-
//
|
|
150
|
+
// literals here (email-template-registry-type). Dynamic markup is rendered
|
|
151
|
+
// here and passed as merge vars — the row owns copy + layout; colours +
|
|
152
|
+
// logo come from the org brand tokens, NOT inline literals.
|
|
140
153
|
const pillarRows = (
|
|
141
154
|
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
142
155
|
)
|
|
143
156
|
.map(
|
|
144
157
|
([name, score]) =>
|
|
145
|
-
`<tr><td style="padding:6px 0;color
|
|
158
|
+
`<tr><td style="padding:6px 0;color:${muted}">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:${primary}">${score} / 100</td></tr>`,
|
|
146
159
|
)
|
|
147
160
|
.join("");
|
|
148
161
|
const pillarsText = (
|
|
@@ -156,27 +169,80 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
156
169
|
bandLabel: opts.bandLabel,
|
|
157
170
|
pillarRows,
|
|
158
171
|
pillarsText,
|
|
172
|
+
brandPrimaryColor: primary,
|
|
173
|
+
brandAccentColor: sender.brand.accentColor ?? primary,
|
|
174
|
+
brandLogoHtml: logoHtml,
|
|
175
|
+
orgName: sender.brand.orgName,
|
|
159
176
|
ctaHtml: opts.ctaUrl
|
|
160
|
-
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background
|
|
177
|
+
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:${primary};color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
|
|
161
178
|
: "",
|
|
162
179
|
ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
|
|
163
180
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
|
|
182
|
+
const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
|
|
183
|
+
sendEmail({
|
|
184
|
+
from: fromHeader,
|
|
185
|
+
to: opts.to,
|
|
186
|
+
subject: tpl.subject,
|
|
187
|
+
html: tpl.bodyHtml,
|
|
188
|
+
text: tpl.bodyText,
|
|
189
|
+
reply_to: replyToHeader,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
let res = await sendOnce(sender.from, sender.replyTo);
|
|
193
|
+
let usedFrom = sender.from;
|
|
194
|
+
if (!res.success) {
|
|
195
|
+
// Resend refused the org sender (likely unverified domain). Fall back
|
|
196
|
+
// to the platform sender; never fail because a verification lookup
|
|
197
|
+
// failed — the resolver returns the row, the failure is at Resend.
|
|
198
|
+
const platformRow = await loadRegistryConfig(
|
|
199
|
+
"config",
|
|
200
|
+
"email-defaults",
|
|
201
|
+
v.looseObject({ default_sender: v.optional(v.string()) }),
|
|
202
|
+
).catch(() => null);
|
|
203
|
+
const fallbackFrom =
|
|
204
|
+
(platformRow?.default_sender as string | undefined)?.trim() ||
|
|
205
|
+
"scala <noreply@gateway.scala.business>";
|
|
206
|
+
log.warn(
|
|
207
|
+
`[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
|
|
208
|
+
);
|
|
209
|
+
res = await sendOnce(fallbackFrom, undefined);
|
|
210
|
+
usedFrom = fallbackFrom;
|
|
211
|
+
}
|
|
212
|
+
|
|
171
213
|
if (res.success) {
|
|
172
214
|
log.info(
|
|
173
|
-
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
|
|
215
|
+
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
|
|
174
216
|
);
|
|
175
217
|
} else {
|
|
176
218
|
log.error(
|
|
177
219
|
`[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
|
|
178
220
|
);
|
|
179
221
|
}
|
|
222
|
+
|
|
223
|
+
// email-send-audited: best-effort audit row. Guarded so a stale prisma
|
|
224
|
+
// client (scala-quiz schema predates email_send_log) skips cleanly.
|
|
225
|
+
try {
|
|
226
|
+
const auditPrisma = opts.prisma as {
|
|
227
|
+
email_send_log?: { create: (args: unknown) => Promise<unknown> };
|
|
228
|
+
};
|
|
229
|
+
if (auditPrisma?.email_send_log) {
|
|
230
|
+
await auditPrisma.email_send_log.create({
|
|
231
|
+
data: {
|
|
232
|
+
to_address: opts.to,
|
|
233
|
+
template_slug: "authority-index-report",
|
|
234
|
+
status: res.success ? "sent" : "failed",
|
|
235
|
+
provider_message_id: res.id ?? null,
|
|
236
|
+
error: res.success ? null : (res.error ?? "unknown"),
|
|
237
|
+
from_address: usedFrom,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
} catch (auditErr) {
|
|
242
|
+
log.warn(
|
|
243
|
+
`[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
180
246
|
}
|
|
181
247
|
|
|
182
248
|
function scoreAnswers(
|
|
@@ -375,6 +441,21 @@ export const publicAssessmentRouter = {
|
|
|
375
441
|
(typeof assessment.cta_url === "string" && assessment.cta_url) ||
|
|
376
442
|
undefined;
|
|
377
443
|
if (band) {
|
|
444
|
+
const prisma = (context as { prisma: unknown }).prisma;
|
|
445
|
+
const brandRow = opts.organisationId
|
|
446
|
+
? await fetchOrgBrand(
|
|
447
|
+
prisma as Parameters<typeof fetchOrgBrand>[0],
|
|
448
|
+
opts.organisationId,
|
|
449
|
+
).catch(() => null)
|
|
450
|
+
: null;
|
|
451
|
+
const brand = brandRow
|
|
452
|
+
? {
|
|
453
|
+
orgName: brandRow.companyName ?? "",
|
|
454
|
+
primaryColor: brandRow.primaryColor,
|
|
455
|
+
accentColor: brandRow.accentColor ?? null,
|
|
456
|
+
logoUrl: brandRow.logo.light ?? null,
|
|
457
|
+
}
|
|
458
|
+
: undefined;
|
|
378
459
|
void sendAuthorityReportEmail({
|
|
379
460
|
to: email,
|
|
380
461
|
firstName,
|
|
@@ -382,6 +463,9 @@ export const publicAssessmentRouter = {
|
|
|
382
463
|
bandLabel: band.label,
|
|
383
464
|
categoryScores,
|
|
384
465
|
ctaUrl,
|
|
466
|
+
organisationId: opts.organisationId,
|
|
467
|
+
brand,
|
|
468
|
+
prisma,
|
|
385
469
|
}).catch((e) =>
|
|
386
470
|
log.error(
|
|
387
471
|
`[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
|