// The brand as a share image and an email show it (spec §10.2, §10.3): the OG image of an app page or an // organization's page, and a transactional email's header. Both draw the same thing, a name and a logo, so // both start from `shareBrand`, which decides them once. // // The name is the organization's display name, else its own name, else the app brand's. The logo is the one // `resolveLogo` picks for the dark page both of them sit on (the organization's own, the organization's // other one when it has only one, or the app brand's), and it goes through the kit's logo checks wherever it // came from: an organization's logo as stored, the app brand's as a file of apps/web/public. // // A logo that cannot be drawn does not stop a card or a mail: the app brand's logo stands in, and the reason // is the kit's, naming a rule and not the file. import { tokens } from "@conusai-kit/brand" import { emailHeader, emailHeaderLogo } from "@conusai-kit/brand/email" import type { LogoSource } from "@conusai-kit/brand/logo/check" import { renderOgImage } from "@conusai-kit/brand/og" import { type BrandView, type LogoVariant, resolveLogo } from "../contract/index.js" /** What the app brand (config/src/brand.ts) contributes: its name, and the paths of its two logos. */ export interface AppIdentity { readonly name: string readonly logo: Readonly> } /** Where the logos are read from: the organization's stored logo, and a file of the app's public folder. */ export interface ShareReaders { tenantLogo(variant: LogoVariant): Promise appLogo(path: string): Promise } export interface ShareBrand { /** The name the card or the mail carries. */ readonly name: string /** The logo to draw; an organization's when it has one. */ readonly logo: LogoSource /** The app brand's own logo, which stands in when `logo` cannot be drawn. */ readonly fallback: LogoSource } export async function shareBrand(input: { readonly app: AppIdentity /** The organization the card or mail is for, or null for the app's own. */ readonly tenant: { readonly name: string; readonly brand: BrandView } | null readonly readers: ShareReaders /** The page the logo sits on; both a share card and a mail header are dark. */ readonly theme?: LogoVariant }): Promise { const theme = input.theme ?? "dark" const choice = resolveLogo(input.tenant?.brand ?? null, input.app.logo, theme) const name = input.tenant?.brand.displayName ?? input.tenant?.name ?? input.app.name if (choice.source === "app") { const logo = await input.readers.appLogo(input.app.logo[theme]) return { name, logo, fallback: logo } } const logo = await input.readers.tenantLogo(choice.variant) // The app brand's logo only stands in; an organization with a good logo is not stopped by a missing app file. const fallback = await input.readers.appLogo(input.app.logo[theme]).catch(() => logo) return { name, logo, fallback } } /** True for the error the kit's renderers throw when the logo they were given is refused. */ const isLogoRefusal = (error: unknown): boolean => error instanceof Error && /the logo cannot be used/.test(error.message) /** Tries the chosen logo, then the app brand's; any other failure is not about the logo and is thrown. */ async function withLogo(share: ShareBrand, draw: (logo: LogoSource) => Promise): Promise { try { return await draw(share.logo) } catch (error) { if (share.logo === share.fallback || !isLogoRefusal(error)) throw error return draw(share.fallback) } } export interface ShareText { readonly title: string readonly subtitle?: string readonly footer?: string } /** The share image of a page: 1200x630 PNG in the brand's treatment, the organization's logo if it has one. */ export function renderShareImage(share: ShareBrand, text: ShareText): Promise { return withLogo(share, (logo) => renderOgImage({ ...text, logo })) } export interface EmailContent { readonly subject: string /** The body, one string per paragraph, as plain text: it is escaped, never taken as markup. */ readonly paragraphs: readonly string[] readonly locale?: string } export interface EmailAttachment { readonly cid: string readonly contentType: "image/png" readonly content: Uint8Array } export interface ComposedEmail { readonly subject: string readonly html: string readonly text: string /** The logo, inline: the HTML refers to it as `cid:`, so the mail names no host. */ readonly attachments: readonly EmailAttachment[] } /** The port an app implements to send mail (SMTP, a provider's API): this module composes and does not deliver. */ export interface MailTransport { send(message: ComposedEmail & { readonly to: string }): Promise } const escapeHtml = (text: string): string => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`) const LINE_BREAK = /[\r\n]/ /** A transactional email in the brand: the header with the logo, the paragraphs, and the name at the foot. */ export async function composeEmail(share: ShareBrand, content: EmailContent): Promise { if (LINE_BREAK.test(content.subject)) throw new Error("composeEmail: the subject is one line") if (content.paragraphs.length === 0) throw new Error("composeEmail: a mail has at least one paragraph") const logo = await withLogo(share, (source) => emailHeaderLogo(source)) const dark = tokens.color.dark const fonts = `'${tokens.typography.families.sans}', ${tokens.typography.fallbacks.sans}` const cell = `padding:24px 32px;font-family:${fonts};color:${dark.foreground};line-height:${tokens.typography.roles.body.lineHeight};` const body = content.paragraphs .map((paragraph) => `

${escapeHtml(paragraph)}

`) .join("") const html = `` + `` + emailHeader({ alt: share.name, aspect: logo.aspect }) + `` + `` + `
${body}
${escapeHtml(share.name)}
` return { subject: `${content.subject} - ${share.name}`, html, text: `${content.paragraphs.join("\n\n")}\n\n${share.name}\n`, attachments: [{ cid: logo.cid, contentType: logo.contentType, content: logo.content }], } } /** Composes the mail and hands it to the app's transport. `to` is one address and cannot carry a header. */ export async function sendEmail( transport: MailTransport, share: ShareBrand, to: string, content: EmailContent, ): Promise { if (LINE_BREAK.test(to) || !/^[^\s@,;<>]+@[^\s@,;<>]+$/.test(to)) throw new Error("sendEmail: to is one email address") await transport.send({ ...(await composeEmail(share, content)), to }) }