a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

update post email template

Jared Pereira (Apr 27, 2026, 3:08 PM EDT) fad3f413 ee7ab360

+735 -243
+1
emails/fromPublication.ts
··· 40 40 accentText: colorToCss(theme.accentText, defaultEmailTheme.accentText), 41 41 headingFont: getFontFamilyValue(getFontConfig(theme.headingFont)), 42 42 bodyFont: getFontFamilyValue(getFontConfig(theme.bodyFont)), 43 + pageWidth: theme.pageWidth ?? defaultEmailTheme.pageWidth, 43 44 }; 44 45 }; 45 46
+734 -243
emails/post.tsx
··· 1 1 import { 2 2 Body, 3 3 Column, 4 - Container, 5 4 Head, 6 5 Heading as ReactEmailHeading, 7 6 Hr, ··· 11 10 Text as ReactEmailText, 12 11 Section, 13 12 Row, 14 - Button, 15 13 CodeBlock as ReactEmailCodeBlock, 16 14 dracula, 17 15 } from "@react-email/components"; 18 16 import type { PrismLanguage } from "@react-email/code-block"; 19 - import { Tailwind, pixelBasedPreset } from "@react-email/components"; 20 - import React from "react"; 17 + import React, { type CSSProperties } from "react"; 21 18 import { 22 19 PubLeafletBlocksBlockquote, 23 20 PubLeafletBlocksCode, ··· 40 37 accentText: string; 41 38 headingFont: string; 42 39 bodyFont: string; 40 + // Matches the publication's web `pageWidth` (px) so subscribers see the 41 + // post at the same column width in their inbox as on the live page. 42 + // Default 624 mirrors `ThemeProvider.tsx`'s fallback. 43 + pageWidth: number; 43 44 }; 44 45 45 46 export const defaultEmailTheme: EmailTheme = { ··· 50 51 accentText: "rgb(255, 255, 255)", 51 52 headingFont: "Georgia, serif", 52 53 bodyFont: "Verdana, sans-serif", 54 + pageWidth: 624, 55 + }; 56 + 57 + // Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — 58 + // theme colors come from a typed config so this is just defensive. 59 + const parseColor = (input: string): [number, number, number] => { 60 + const rgbMatch = input.match( 61 + /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i, 62 + ); 63 + if (rgbMatch) 64 + return [ 65 + Number(rgbMatch[1]), 66 + Number(rgbMatch[2]), 67 + Number(rgbMatch[3]), 68 + ]; 69 + const hexMatch = input.match(/^#([0-9a-f]{6})$/i); 70 + if (hexMatch) { 71 + const h = hexMatch[1]; 72 + return [ 73 + parseInt(h.slice(0, 2), 16), 74 + parseInt(h.slice(2, 4), 16), 75 + parseInt(h.slice(4, 6), 16), 76 + ]; 77 + } 78 + const hex3 = input.match(/^#([0-9a-f]{3})$/i); 79 + if (hex3) { 80 + const h = hex3[1]; 81 + return [ 82 + parseInt(h[0] + h[0], 16), 83 + parseInt(h[1] + h[1], 16), 84 + parseInt(h[2] + h[2], 16), 85 + ]; 86 + } 87 + return [0, 0, 0]; 88 + }; 89 + 90 + // Linear sRGB mix of two colors. We resolve theme tints to literal rgb() at 91 + // render time because Gmail's CSS sanitizer drops `color-mix(...)` — leaving 92 + // borders invisible and accent text fall back to defaults. Linear mixing 93 + // isn't perceptually identical to the oklab original, but for the 94 + // near-grayscale tints we use it's visually indistinguishable. 95 + const mixRgb = ( 96 + a: string, 97 + b: string, 98 + bPercent: number, 99 + ): string => { 100 + const [ar, ag, ab] = parseColor(a); 101 + const [br, bg, bb] = parseColor(b); 102 + const t = bPercent / 100; 103 + const round = (x: number) => Math.round(x); 104 + return `rgb(${round(ar * (1 - t) + br * t)}, ${round( 105 + ag * (1 - t) + bg * t, 106 + )}, ${round(ab * (1 - t) + bb * t)})`; 53 107 }; 54 108 109 + type ResolvedColors = { 110 + primary: string; 111 + secondary: string; 112 + tertiary: string; 113 + border: string; 114 + borderLight: string; 115 + }; 116 + 117 + const resolveColors = (theme: EmailTheme): ResolvedColors => ({ 118 + primary: theme.primary, 119 + secondary: mixRgb(theme.primary, theme.pageBackground, 25), 120 + tertiary: mixRgb(theme.primary, theme.pageBackground, 55), 121 + border: mixRgb(theme.primary, theme.pageBackground, 75), 122 + borderLight: mixRgb(theme.primary, theme.pageBackground, 85), 123 + }); 124 + 55 125 export type PostEmailProps = { 56 126 publicationName: string; 57 127 publicationUrl: string; ··· 184 254 ], 185 255 }; 186 256 257 + const BLOCK_MARGIN = "4px 0 16px"; 258 + const HEADING_MARGIN = "4px 0 0"; 259 + 187 260 export const PostEmail = (props: Partial<PostEmailProps> = {}) => { 188 261 const p: PostEmailProps = { ...defaultProps, ...props }; 189 262 const theme = p.theme ?? defaultEmailTheme; 263 + const c = resolveColors(theme); 190 264 const staticUrl = (filename: string) => 191 265 `${p.assetsBaseUrl.replace(/\/$/, "")}/email-assets/${filename}`; 192 266 const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | "); 193 267 268 + const accentLink: CSSProperties = { 269 + color: theme.accentBackground, 270 + textDecoration: "none", 271 + fontFamily: theme.bodyFont, 272 + }; 273 + 274 + // Page width as both an HTML width attribute (px) and a CSS max-width. 275 + // Gmail strips/ignores `max-width` in some contexts but always honors the 276 + // HTML `width` attribute on a <table>, so we set both: the HTML `width` 277 + // pins the box for Gmail, and `maxWidth: 100%` lets it shrink on narrow 278 + // viewports. The value mirrors the publication's web page width. 279 + const pageWidth = theme.pageWidth; 280 + 194 281 return ( 195 282 <Html> 196 - <Tailwind 197 - config={{ 198 - presets: [pixelBasedPreset], 199 - theme: { 200 - screens: { 201 - sm: "640px", 202 - md: "960px", 203 - lg: "1280px", 204 - }, 205 - borderRadius: { 206 - none: "0", 207 - md: "0.25rem", 208 - lg: "0.5rem", 209 - full: "9999px", 210 - }, 211 - colors: { 212 - inherit: "inherit", 213 - transparent: "transparent", 214 - current: "currentColor", 215 - primary: theme.primary, 216 - secondary: `color-mix(in oklab, ${theme.primary}, ${theme.pageBackground} 25%)`, 217 - tertiary: `color-mix(in oklab, ${theme.primary}, ${theme.pageBackground} 55%)`, 218 - border: `color-mix(in oklab, ${theme.primary}, ${theme.pageBackground} 75%)`, 219 - "border-light": `color-mix(in oklab, ${theme.primary}, ${theme.pageBackground} 85%)`, 220 - white: "#FFFFFF", 221 - "accent-1": theme.accentBackground, 222 - "accent-2": theme.accentText, 223 - "accent-contrast": theme.accentBackground, 224 - "bg-leaflet": theme.backgroundColor, 225 - "bg-page": theme.pageBackground, 226 - "highlight-1": "rgb(255, 177, 177)", 227 - "highlight-2": "rgb(253, 245, 203)", 228 - "highlight-3": "rgb(255, 205, 195)", 229 - test: "#E18181", 230 - "test-blue": "#48D1EF", 231 - }, 232 - fontSize: { 233 - xs: ".75rem", 234 - sm: ".875rem", 235 - base: "1rem", 236 - lg: "1.125rem", 237 - xl: "1.625rem", 238 - "2xl": "2rem", 239 - }, 240 - extend: { 241 - fontFamily: { 242 - sans: [theme.bodyFont], 243 - serif: [theme.headingFont], 244 - }, 245 - }, 246 - }, 283 + <Head> 284 + {/* Without this, iOS Mail / Gmail iOS auto-scale the email down to 285 + fit a wider-than-viewport layout (e.g. 624px in a 400px screen) 286 + — which makes every element appear "too small". With it, we get 287 + 1:1 pixel sizing and our @media queries below can shrink the 288 + card to viewport width directly. */} 289 + <meta 290 + name="viewport" 291 + content="width=device-width, initial-scale=1.0" 292 + /> 293 + {/* Mobile-only padding tightening. Apple Mail, iOS Mail, Gmail web, 294 + and most other clients honor @media in <head>; Outlook desktop 295 + ignores it and keeps the wider inline padding, which is fine 296 + (it's a desktop client). !important is required so the 297 + media-query rule beats the inline `style.padding`. */} 298 + <style>{` 299 + @media only screen and (max-width: 480px) { 300 + .email-page-pad { padding: 12px 8px !important; } 301 + .email-card-pad { padding: 16px !important; } 302 + /* The card table carries an HTML width attribute (e.g. 624) so 303 + Gmail anchors on it; on mobile that pins it wider than the 304 + viewport. Force it to fit. */ 305 + .email-card-table { 306 + width: 100% !important; 307 + max-width: 100% !important; 308 + } 309 + } 310 + `}</style> 311 + </Head> 312 + <Body 313 + style={{ 314 + backgroundColor: theme.backgroundColor, 315 + color: theme.primary, 316 + fontFamily: theme.bodyFont, 317 + margin: 0, 318 + padding: 0, 247 319 }} 248 320 > 249 - <Head /> 250 - <Body className={`bg-bg-leaflet font-sans p-2 sm:px-4 sm:py-6 !m-0 `}> 251 - <Container 252 - className={`bg-bg-page rounded-lg border border-border mx-auto px-4 sm:px-6`} 253 - style={{ maxWidth: "28rem" }} 254 - > 255 - <Button 256 - href={p.publicationUrl} 257 - className={`${link} font-bold !my-0`} 258 - > 259 - {p.publicationName} 260 - </Button> 321 + {/* Page-background wrapper. Gmail strips/replaces <body> styling, so 322 + we paint the background on this table's <td> using both the 323 + bgcolor HTML attribute (Outlook/Gmail bulletproof) and a 324 + backgroundColor style (everything else). */} 325 + <table 326 + role="presentation" 327 + width="100%" 328 + cellPadding={0} 329 + cellSpacing={0} 330 + border={0} 331 + bgcolor={theme.backgroundColor} 332 + style={{ 333 + width: "100%", 334 + backgroundColor: theme.backgroundColor, 335 + }} 336 + > 337 + <tbody> 338 + <tr> 339 + <td 340 + align="center" 341 + className="email-page-pad" 342 + {...({ bgcolor: theme.backgroundColor } as Record<string, string>)} 343 + style={{ 344 + backgroundColor: theme.backgroundColor, 345 + padding: "24px 16px", 346 + }} 347 + > 348 + <table 349 + role="presentation" 350 + align="center" 351 + className="email-card-table" 352 + width={pageWidth} 353 + cellPadding={0} 354 + cellSpacing={0} 355 + border={0} 356 + style={{ width: pageWidth, maxWidth: "100%" }} 357 + > 358 + <tbody> 359 + <tr> 360 + <td 361 + className="email-card-pad" 362 + {...({ bgcolor: theme.pageBackground } as Record< 363 + string, 364 + string 365 + >)} 366 + style={{ 367 + backgroundColor: theme.pageBackground, 368 + border: `1px solid ${c.border}`, 369 + borderRadius: 8, 370 + padding: "20px 24px", 371 + }} 372 + > 373 + <Link 374 + href={p.publicationUrl} 375 + style={{ 376 + ...accentLink, 377 + fontWeight: "bold", 378 + fontSize: 16, 379 + }} 380 + > 381 + {p.publicationName} 382 + </Link> 383 + 384 + <ReactEmailHeading 385 + as="h1" 386 + style={{ 387 + color: theme.primary, 388 + fontFamily: theme.headingFont, 389 + fontWeight: "bold", 390 + fontSize: 26, 391 + lineHeight: 1.2, 392 + margin: "8px 0 0", 393 + }} 394 + > 395 + {p.postTitle} 396 + </ReactEmailHeading> 261 397 262 - <Heading as="h1" noPadding> 263 - {p.postTitle} 264 - </Heading> 265 - {p.postDescription ? ( 266 - <Text noPadding className={`text-secondary italic pt-1`}> 267 - {p.postDescription} 268 - </Text> 269 - ) : null} 398 + {p.postDescription ? ( 399 + <ReactEmailText 400 + style={{ 401 + color: c.secondary, 402 + fontFamily: theme.bodyFont, 403 + fontSize: 16, 404 + fontStyle: "italic", 405 + lineHeight: 1.4, 406 + margin: "4px 0 0", 407 + }} 408 + > 409 + {p.postDescription} 410 + </ReactEmailText> 411 + ) : null} 270 412 271 - {byline ? ( 272 - <Section className={`postActions !mb-7 !mt-3`}> 273 - <Row> 274 - <Column width="auto"> 275 - <Text className="text-sm text-tertiary !my-0"> 276 - {byline} 277 - </Text> 278 - </Column> 279 - <Column width="12px" /> 280 - <Column style={{ width: "16px" }}> 281 - <Button href={drawerUrl(p.postUrl, "quotes")}> 282 - <Img 283 - width={16} 284 - height={16} 285 - src={staticUrl("quote.png")} 286 - alt="See quotes" 287 - /> 288 - </Button> 289 - </Column> 290 - <Column width="8px" /> 291 - <Column style={{ width: "16px" }}> 292 - <Button href={drawerUrl(p.postUrl, "comments")}> 293 - <Img 294 - width={16} 295 - height={16} 296 - src={staticUrl("comment.png")} 297 - alt="See comments" 298 - /> 299 - </Button> 300 - </Column> 301 - <Column width="10px" /> 302 - <Column style={{ width: "16px" }}> 303 - <Button href={p.postUrl}> 304 - <Img 305 - width={16} 306 - height={16} 307 - src={staticUrl("external-link.png")} 308 - alt="Open post" 309 - /> 310 - </Button> 311 - </Column> 312 - <Column width="inherit" /> 313 - </Row> 314 - </Section> 315 - ) : null} 316 - <Section className="postContent"> 317 - {p.blocks.map((b, i) => ( 318 - <BlockRenderer 319 - key={i} 320 - block={b.block} 321 - did={p.did} 322 - assetsBaseUrl={p.assetsBaseUrl} 323 - /> 324 - ))} 325 - </Section> 326 - <Section className="pt-4"> 327 - <Text noPadding className="text-center leading-5"> 328 - <Button 329 - href={p.postUrl} 330 - className={`${link} font-bold text-sm leading-5 !my-0`} 413 + {byline ? ( 414 + <Section 415 + style={{ margin: "12px 0 28px", minWidth: "100%" }} 416 + > 417 + <Row style={{ minWidth: "100%" }}> 418 + <Column style={{ verticalAlign: "middle" }}> 419 + <ReactEmailText 420 + style={{ 421 + color: c.tertiary, 422 + fontFamily: theme.bodyFont, 423 + fontSize: 14, 424 + lineHeight: 1.4, 425 + margin: 0, 426 + }} 427 + > 428 + {byline} 429 + </ReactEmailText> 430 + </Column> 431 + <Column style={{ width: 12 }} /> 432 + <Column style={{ width: 16, verticalAlign: "middle" }}> 433 + <Link 434 + href={drawerUrl(p.postUrl, "quotes")} 435 + style={accentLink} 436 + > 437 + <Img 438 + width={16} 439 + height={16} 440 + src={staticUrl("quote.png")} 441 + alt="See quotes" 442 + /> 443 + </Link> 444 + </Column> 445 + <Column style={{ width: 8 }} /> 446 + <Column style={{ width: 16, verticalAlign: "middle" }}> 447 + <Link 448 + href={drawerUrl(p.postUrl, "comments")} 449 + style={accentLink} 450 + > 451 + <Img 452 + width={16} 453 + height={16} 454 + src={staticUrl("comment.png")} 455 + alt="See comments" 456 + /> 457 + </Link> 458 + </Column> 459 + <Column style={{ width: 10 }} /> 460 + <Column style={{ width: 16, verticalAlign: "middle" }}> 461 + <Link href={p.postUrl} style={accentLink}> 462 + <Img 463 + width={16} 464 + height={16} 465 + src={staticUrl("external-link.png")} 466 + alt="Open post" 467 + /> 468 + </Link> 469 + </Column> 470 + </Row> 471 + </Section> 472 + ) : null} 473 + 474 + {p.blocks.map((b, i) => ( 475 + <BlockRenderer 476 + key={i} 477 + block={b.block} 478 + did={p.did} 479 + assetsBaseUrl={p.assetsBaseUrl} 480 + theme={theme} 481 + colors={c} 482 + /> 483 + ))} 484 + 485 + {/* Footer: Gmail won't reliably cascade `text-align` from a 486 + wrapping <table>, so each centered row is its own <td 487 + align="center"> — the bulletproof email-centering 488 + pattern. `min-width: 100%` keeps Gmail iOS from 489 + shrink-wrapping the table around the short link text. */} 490 + <table 491 + role="presentation" 492 + width="100%" 493 + cellPadding={0} 494 + cellSpacing={0} 495 + border={0} 496 + style={{ width: "100%", minWidth: "100%" }} 331 497 > 332 - See Full Post 333 - </Button> 334 - </Text> 335 - <Text 336 - noPadding 337 - className="text-sm text-tertiary text-center leading-5" 338 - > 339 - {p.unsubscribeUrl ? ( 340 - <Button 341 - href={p.unsubscribeUrl} 342 - className={`leading-5 !my-0`} 343 - > 344 - Unsubscribe 345 - </Button> 346 - ) : ( 347 - <span className={`leading-5 !my-0 italic`}> 348 - (preview — not sent to subscribers) 349 - </span> 350 - )} 351 - </Text> 352 - </Section> 353 - </Container> 354 - <Hr className="border-border-light my-3" /> 498 + <tbody> 499 + <tr> 500 + <td align="center" style={{ paddingTop: 16 }}> 501 + <Link 502 + href={p.postUrl} 503 + style={{ 504 + ...accentLink, 505 + fontWeight: "bold", 506 + fontSize: 14, 507 + lineHeight: "20px", 508 + }} 509 + > 510 + See Full Post 511 + </Link> 512 + </td> 513 + </tr> 514 + <tr> 515 + <td 516 + align="center" 517 + style={{ 518 + color: c.tertiary, 519 + fontFamily: theme.bodyFont, 520 + fontSize: 14, 521 + lineHeight: "20px", 522 + paddingTop: 8, 523 + }} 524 + > 525 + {p.unsubscribeUrl ? ( 526 + <Link 527 + href={p.unsubscribeUrl} 528 + style={{ 529 + color: c.tertiary, 530 + fontSize: 14, 531 + lineHeight: "20px", 532 + textDecoration: "underline", 533 + }} 534 + > 535 + Unsubscribe 536 + </Link> 537 + ) : ( 538 + <span style={{ fontStyle: "italic" }}> 539 + (preview — not sent to subscribers) 540 + </span> 541 + )} 542 + </td> 543 + </tr> 544 + </tbody> 545 + </table> 546 + </td> 547 + </tr> 548 + 549 + {/* Spacer */} 550 + <tr> 551 + <td 552 + style={{ 553 + fontSize: 0, 554 + height: 12, 555 + lineHeight: "12px", 556 + }} 557 + > 558 + &nbsp; 559 + </td> 560 + </tr> 561 + 562 + {/* Horizontal rule between card and watermark. <hr> 563 + margins are flaky in Gmail, so we use a 1px-tall 564 + <td> with border-top instead. */} 565 + <tr> 566 + <td 567 + style={{ 568 + borderTop: `1px solid ${c.borderLight}`, 569 + fontSize: 0, 570 + height: 1, 571 + lineHeight: "1px", 572 + }} 573 + > 574 + &nbsp; 575 + </td> 576 + </tr> 355 577 356 - <LeafletWatermark staticUrl={staticUrl} /> 357 - </Body> 358 - </Tailwind> 578 + <tr> 579 + <td 580 + style={{ 581 + fontSize: 0, 582 + height: 12, 583 + lineHeight: "12px", 584 + }} 585 + > 586 + &nbsp; 587 + </td> 588 + </tr> 589 + 590 + <tr> 591 + <td align="center"> 592 + <LeafletWatermark 593 + theme={theme} 594 + staticUrl={staticUrl} 595 + /> 596 + </td> 597 + </tr> 598 + </tbody> 599 + </table> 600 + </td> 601 + </tr> 602 + </tbody> 603 + </table> 604 + </Body> 359 605 </Html> 360 606 ); 361 607 }; ··· 365 611 block, 366 612 did, 367 613 assetsBaseUrl, 614 + theme, 615 + colors, 368 616 }: { 369 617 block: PubLeafletPagesLinearDocument.Block["block"]; 370 618 did: string; 371 619 assetsBaseUrl: string; 620 + theme: EmailTheme; 621 + colors: ResolvedColors; 372 622 }) => { 373 623 if (PubLeafletBlocksText.isMain(block)) { 374 - return <Text>{block.plaintext || " "}</Text>; 624 + return ( 625 + <ReactEmailText 626 + style={{ 627 + color: theme.primary, 628 + fontFamily: theme.bodyFont, 629 + fontSize: 16, 630 + lineHeight: 1.5, 631 + margin: BLOCK_MARGIN, 632 + }} 633 + > 634 + {block.plaintext || " "} 635 + </ReactEmailText> 636 + ); 375 637 } 376 638 if (PubLeafletBlocksHeader.isMain(block)) { 377 639 const raw = Math.floor(block.level ?? 1); 378 640 const clamped = (raw < 1 ? 1 : raw > 3 ? 3 : raw) as 1 | 2 | 3; 379 - return <Heading as={`h${clamped}`}>{block.plaintext}</Heading>; 641 + const fontSize = clamped === 1 ? 26 : clamped === 2 ? 18 : 16; 642 + const color = clamped === 3 ? colors.secondary : theme.primary; 643 + return ( 644 + <ReactEmailHeading 645 + as={`h${clamped}`} 646 + style={{ 647 + color, 648 + fontFamily: theme.headingFont, 649 + fontWeight: "bold", 650 + fontSize, 651 + lineHeight: 1.25, 652 + margin: HEADING_MARGIN, 653 + }} 654 + > 655 + {block.plaintext} 656 + </ReactEmailHeading> 657 + ); 380 658 } 381 659 if (PubLeafletBlocksBlockquote.isMain(block)) { 382 660 return ( 383 - <Row className={blockPadding}> 384 - <Column className="!my-0 w-[2px] bg-border" /> 385 - <Column className="w-2" /> 386 - <Column> 387 - <Text className="!my-0.5">{block.plaintext}</Text> 388 - </Column> 389 - </Row> 661 + <Section style={{ margin: BLOCK_MARGIN }}> 662 + <Row> 663 + <Column style={{ width: 2, backgroundColor: colors.border }} /> 664 + <Column style={{ width: 8 }} /> 665 + <Column> 666 + <ReactEmailText 667 + style={{ 668 + color: theme.primary, 669 + fontFamily: theme.bodyFont, 670 + fontSize: 16, 671 + lineHeight: 1.5, 672 + margin: "2px 0", 673 + }} 674 + > 675 + {block.plaintext} 676 + </ReactEmailText> 677 + </Column> 678 + </Row> 679 + </Section> 390 680 ); 391 681 } 392 682 if (PubLeafletBlocksCode.isMain(block)) { 393 - return <CodeBlock code={block.plaintext} language={block.language} />; 683 + return ( 684 + <CodeBlock 685 + code={block.plaintext} 686 + language={block.language} 687 + borderColor={colors.borderLight} 688 + /> 689 + ); 394 690 } 395 691 if (PubLeafletBlocksImage.isMain(block)) { 396 692 const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl); ··· 403 699 <Img 404 700 src={src} 405 701 alt={block.alt ?? ""} 406 - className={`${blockPadding} mx-auto`} 407 702 style={{ 408 703 display: "block", 409 - width: "100%", 704 + height: "auto", 705 + margin: `${BLOCK_MARGIN.split(" ")[0]} auto ${ 706 + BLOCK_MARGIN.split(" ").slice(-1)[0] 707 + }`, 410 708 maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", 411 - height: "auto", 709 + width: "100%", 412 710 }} 413 711 /> 414 712 ); ··· 423 721 title={block.title} 424 722 description={block.description} 425 723 previewSrc={previewSrc} 724 + theme={theme} 725 + colors={colors} 426 726 /> 427 727 ); 428 728 } 429 729 if (PubLeafletBlocksHorizontalRule.isMain(block)) { 430 - return <Hr className="border-border-light my-3" />; 730 + return ( 731 + <Hr 732 + style={{ 733 + border: "none", 734 + borderTop: `1px solid ${colors.borderLight}`, 735 + margin: "12px 0", 736 + width: "100%", 737 + }} 738 + /> 739 + ); 431 740 } 432 741 if (PubLeafletBlocksUnorderedList.isMain(block)) { 433 742 return ( 434 - <List items={block.children} style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} /> 743 + <List 744 + items={block.children} 745 + style="unordered" 746 + did={did} 747 + assetsBaseUrl={assetsBaseUrl} 748 + theme={theme} 749 + /> 435 750 ); 436 751 } 437 752 if (PubLeafletBlocksOrderedList.isMain(block)) { 438 753 return ( 439 - <List items={block.children} style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} /> 754 + <List 755 + items={block.children} 756 + style="ordered" 757 + did={did} 758 + assetsBaseUrl={assetsBaseUrl} 759 + theme={theme} 760 + /> 440 761 ); 441 762 } 442 - return <BlockNotSupported />; 763 + return <BlockNotSupported theme={theme} colors={colors} />; 443 764 }; 444 765 445 766 export const LeafletWatermark = ({ 446 767 staticUrl, 768 + theme = defaultEmailTheme, 447 769 }: { 448 770 staticUrl?: (filename: string) => string; 771 + theme?: EmailTheme; 449 772 } = {}) => { 773 + const c = resolveColors(theme); 450 774 const leafletSrc = staticUrl 451 775 ? staticUrl("leaflet.png") 452 776 : "/email-assets/leaflet.png"; 777 + // Shrink-to-fit table with align="center" — the bulletproof email 778 + // pattern for centering a chunk of inline content within whatever 779 + // container it lands in. 453 780 return ( 454 - <Container className={` w-fit `}> 455 - <Button href="https://leaflet.pub"> 456 - <Row className={`text-tertiary italic text-sm`}> 457 - <Column style={{ width: "16px" }}> 458 - <Img width={16} height={16} src={leafletSrc} /> 459 - </Column> 460 - <Column width="4px" /> 461 - <Column style={{ width: "164px" }}> 462 - Published with{" "} 463 - <Link className={`${link} font-bold text-sm`}>Leaflet</Link> 464 - </Column> 465 - </Row> 466 - </Button> 467 - </Container> 781 + <table 782 + role="presentation" 783 + align="center" 784 + cellPadding={0} 785 + cellSpacing={0} 786 + border={0} 787 + > 788 + <tbody> 789 + <tr> 790 + <td> 791 + <Link 792 + href="https://leaflet.pub" 793 + style={{ 794 + color: c.tertiary, 795 + fontFamily: theme.bodyFont, 796 + fontSize: 14, 797 + fontStyle: "italic", 798 + textDecoration: "none", 799 + }} 800 + > 801 + <Img 802 + src={leafletSrc} 803 + width={16} 804 + height={16} 805 + alt="" 806 + style={{ 807 + display: "inline-block", 808 + marginRight: 4, 809 + verticalAlign: "middle", 810 + }} 811 + /> 812 + <span style={{ verticalAlign: "middle" }}> 813 + Published with{" "} 814 + <span 815 + style={{ 816 + color: theme.accentBackground, 817 + fontWeight: "bold", 818 + }} 819 + > 820 + Leaflet 821 + </span> 822 + </span> 823 + </Link> 824 + </td> 825 + </tr> 826 + </tbody> 827 + </table> 468 828 ); 469 829 }; 470 830 471 - const blockPadding = "mt-1 mb-3 sm:mb-4"; 472 - const headingPadding = "mt-1 mb-0"; 473 - const link = `text-base text-accent-contrast ${blockPadding}`; 474 - 831 + // Backwards-compatible helpers used by `leafletConfirmEmail.tsx` and 832 + // `pubConfirmEmail.tsx`, which wrap themselves in their own <Tailwind> 833 + // context. We keep layout (margin/font-size/line-height) inline so the 834 + // helpers still look right outside Tailwind, but leave color/font-family 835 + // to the caller (via className inside Tailwind, or a `style` override). 475 836 export const Text = (props: { 476 837 children: React.ReactNode; 477 838 noPadding?: boolean; 478 839 small?: boolean; 479 840 className?: string; 841 + style?: CSSProperties; 480 842 }) => { 843 + const fontSize = props.small ? 14 : 16; 481 844 return ( 482 845 <ReactEmailText 483 - className={`text-primary ${props.small ? "text-sm" : "text-base"} ${props.noPadding ? "!my-0" : blockPadding} ${props.className ?? ""}`} 846 + className={props.className} 847 + style={{ 848 + fontSize, 849 + lineHeight: 1.5, 850 + margin: props.noPadding ? 0 : BLOCK_MARGIN, 851 + ...props.style, 852 + }} 484 853 > 485 854 {props.children} 486 855 </ReactEmailText> ··· 492 861 noPadding?: boolean; 493 862 as: "h1" | "h2" | "h3"; 494 863 className?: string; 864 + style?: CSSProperties; 495 865 }) => { 866 + const fontSize = 867 + props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; 496 868 return ( 497 869 <ReactEmailHeading 498 870 as={props.as} 499 - className={`font-serif font-bold ${props.noPadding ? "!my-0" : headingPadding} ${props.as === "h1" ? "text-xl" : props.as === "h2" ? "text-lg" : "text-base text-secondary"} ${props.className ?? ""}`} 871 + className={props.className} 872 + style={{ 873 + fontWeight: "bold", 874 + fontSize, 875 + lineHeight: 1.25, 876 + margin: props.noPadding ? 0 : HEADING_MARGIN, 877 + ...props.style, 878 + }} 500 879 > 501 880 {props.children} 502 881 </ReactEmailHeading> ··· 519 898 style, 520 899 did, 521 900 assetsBaseUrl, 901 + theme = defaultEmailTheme, 522 902 }: { 523 903 items: ListItem[]; 524 904 style: "ordered" | "unordered"; 525 905 did: string; 526 906 assetsBaseUrl: string; 907 + theme?: EmailTheme; 527 908 }) => { 528 - const listClass = `my-0 !pl-6`; 529 - const listItemClass = `${headingPadding} !ml-2`; 530 909 const Tag = style === "ordered" ? "ol" : "ul"; 531 910 return ( 532 - <Section className={`${blockPadding} !-mt-1`}> 533 - <Tag className={listClass}> 911 + <Section style={{ margin: BLOCK_MARGIN }}> 912 + <Tag 913 + style={{ 914 + color: theme.primary, 915 + fontFamily: theme.bodyFont, 916 + fontSize: 16, 917 + lineHeight: 1.5, 918 + margin: 0, 919 + paddingLeft: 24, 920 + }} 921 + > 534 922 {items.map((item, i) => { 535 923 const plaintext = listItemPlaintext(item); 536 924 const nestedUnordered = ··· 545 933 .orderedListChildren?.children; 546 934 return ( 547 935 <React.Fragment key={i}> 548 - <li className={listItemClass}> 936 + <li style={{ margin: "2px 0", paddingLeft: 4 }}> 549 937 {typeof item.checked === "boolean" 550 938 ? `${item.checked ? "☑ " : "☐ "}${plaintext}` 551 939 : plaintext} ··· 556 944 style="unordered" 557 945 did={did} 558 946 assetsBaseUrl={assetsBaseUrl} 947 + theme={theme} 559 948 /> 560 949 ) : null} 561 950 {nestedOrdered && nestedOrdered.length > 0 ? ( ··· 564 953 style="ordered" 565 954 did={did} 566 955 assetsBaseUrl={assetsBaseUrl} 956 + theme={theme} 567 957 /> 568 958 ) : null} 569 959 </React.Fragment> ··· 579 969 title, 580 970 description, 581 971 previewSrc, 972 + theme = defaultEmailTheme, 973 + colors, 582 974 }: { 583 975 url?: string; 584 976 title?: string; 585 977 description?: string; 586 978 previewSrc?: string; 979 + theme?: EmailTheme; 980 + colors?: ResolvedColors; 587 981 } = {}) => { 982 + const c = colors ?? resolveColors(theme); 588 983 const displayUrl = (() => { 589 984 if (!url) return "www.example.com"; 590 985 try { ··· 594 989 } 595 990 })(); 596 991 return ( 597 - <Row 598 - border={1} 599 - className={`${blockPadding} h-[104px] border-accent-contrast rounded-lg !p-0 border-solid`} 600 - > 601 - <Column 602 - style={{ verticalAlign: "top" }} 603 - className="border-transparent py-1 px-2 " 992 + <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> 993 + <Row 994 + style={{ 995 + border: `1px solid ${theme.accentBackground}`, 996 + borderRadius: 8, 997 + minWidth: "100%", 998 + }} 604 999 > 605 - <Link href={url}> 606 - <Text noPadding className={`font-bold`}> 1000 + <Column 1001 + style={{ 1002 + padding: "8px 12px", 1003 + verticalAlign: "top", 1004 + // Long URL-like titles or domains have no break opportunities 1005 + // and would otherwise force the column wider than the card. 1006 + wordBreak: "break-word", 1007 + }} 1008 + > 1009 + <Link 1010 + href={url} 1011 + style={{ 1012 + color: theme.primary, 1013 + fontFamily: theme.bodyFont, 1014 + fontWeight: "bold", 1015 + fontSize: 16, 1016 + textDecoration: "none", 1017 + wordBreak: "break-word", 1018 + }} 1019 + > 607 1020 {title || displayUrl} 608 - </Text> 609 - </Link> 610 - {description ? ( 611 - <Text noPadding className={`text-secondary`}> 612 - {description} 613 - </Text> 614 - ) : null} 615 - <Text small noPadding className={`text-accent-contrast italic`}> 616 - {displayUrl} 617 - </Text> 618 - </Column> 619 - {previewSrc ? ( 620 - <Column className="border-none w-28 pr-2 pt-2"> 621 - <Img 622 - src={previewSrc} 623 - alt="" 624 - className="rounded-t-md w-full h-full" 625 - style={{ objectFit: "cover" }} 626 - /> 1021 + </Link> 1022 + {description ? ( 1023 + <ReactEmailText 1024 + style={{ 1025 + color: c.secondary, 1026 + fontFamily: theme.bodyFont, 1027 + fontSize: 16, 1028 + lineHeight: 1.4, 1029 + margin: "4px 0 0", 1030 + wordBreak: "break-word", 1031 + }} 1032 + > 1033 + {description} 1034 + </ReactEmailText> 1035 + ) : null} 1036 + <ReactEmailText 1037 + style={{ 1038 + color: theme.accentBackground, 1039 + fontFamily: theme.bodyFont, 1040 + fontSize: 14, 1041 + fontStyle: "italic", 1042 + lineHeight: 1.4, 1043 + margin: "4px 0 0", 1044 + wordBreak: "break-word", 1045 + }} 1046 + > 1047 + {displayUrl} 1048 + </ReactEmailText> 627 1049 </Column> 628 - ) : null} 629 - </Row> 1050 + {previewSrc ? ( 1051 + <Column 1052 + style={{ 1053 + padding: "8px 8px 8px 0", 1054 + verticalAlign: "top", 1055 + width: 112, 1056 + }} 1057 + > 1058 + <Img 1059 + src={previewSrc} 1060 + alt="" 1061 + style={{ 1062 + borderRadius: 4, 1063 + display: "block", 1064 + height: 88, 1065 + objectFit: "cover", 1066 + width: "100%", 1067 + }} 1068 + /> 1069 + </Column> 1070 + ) : null} 1071 + </Row> 1072 + </Section> 630 1073 ); 631 1074 }; 632 1075 633 1076 export const CodeBlock = ({ 634 1077 code, 635 1078 language, 1079 + borderColor, 636 1080 }: { 637 1081 code?: string; 638 1082 language?: string; 1083 + borderColor?: string; 639 1084 } = {}) => { 640 1085 return ( 641 1086 <ReactEmailCodeBlock 642 - className={`${blockPadding} !p-2 rounded-md border border-light`} 1087 + style={{ 1088 + border: `1px solid ${borderColor ?? "rgba(0, 0, 0, 0.1)"}`, 1089 + borderRadius: 4, 1090 + boxSizing: "border-box", 1091 + margin: BLOCK_MARGIN, 1092 + maxWidth: "100%", 1093 + // <pre> defaults to `white-space: pre`, so long lines blow past the 1094 + // card on mobile. Wrap, and break long tokens (URLs, identifiers) 1095 + // when nothing else fits. 1096 + overflow: "hidden", 1097 + padding: 8, 1098 + whiteSpace: "pre-wrap", 1099 + wordBreak: "break-word", 1100 + }} 643 1101 code={code ?? ""} 644 1102 theme={dracula} 645 1103 language={(language as PrismLanguage) || "text"} ··· 647 1105 ); 648 1106 }; 649 1107 650 - export const BlockNotSupported = () => { 1108 + export const BlockNotSupported = ({ 1109 + theme = defaultEmailTheme, 1110 + colors, 1111 + }: { 1112 + theme?: EmailTheme; 1113 + colors?: ResolvedColors; 1114 + } = {}) => { 1115 + const c = colors ?? resolveColors(theme); 651 1116 return ( 652 - <Container 653 - className={`bg-border-light h-20 rounded-md text-tertiary ${blockPadding}`} 1117 + <Section 1118 + style={{ 1119 + backgroundColor: c.borderLight, 1120 + borderRadius: 4, 1121 + margin: BLOCK_MARGIN, 1122 + padding: "16px 12px", 1123 + }} 654 1124 > 655 - <Text noPadding small className={"text-tertiary text-center italic"}> 1125 + <ReactEmailText 1126 + style={{ 1127 + color: c.tertiary, 1128 + fontFamily: theme.bodyFont, 1129 + fontSize: 14, 1130 + fontStyle: "italic", 1131 + lineHeight: 1.4, 1132 + margin: 0, 1133 + textAlign: "center", 1134 + }} 1135 + > 656 1136 This media isn't supported in email... 657 - </Text> 658 - <Text noPadding small className="text-center"> 1137 + </ReactEmailText> 1138 + <ReactEmailText 1139 + style={{ 1140 + fontSize: 14, 1141 + lineHeight: 1.4, 1142 + margin: "4px 0 0", 1143 + textAlign: "center", 1144 + }} 1145 + > 659 1146 <Link 660 - className={`w-full text-accent-contrast text-sm text-center font-bold`} 1147 + style={{ 1148 + color: theme.accentBackground, 1149 + fontWeight: "bold", 1150 + textDecoration: "none", 1151 + }} 661 1152 > 662 1153 See full post 663 1154 </Link> 664 - </Text> 665 - </Container> 1155 + </ReactEmailText> 1156 + </Section> 666 1157 ); 667 1158 };