a tool for shared writing and social publishing
0

Configure Feed

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

email template fixes

Jared Pereira (Apr 28, 2026, 11:26 PM EDT) e100d4ea f8c4c843

+185 -54
+16 -27
actions/emailAuth.ts actions/emailAuth.tsx
··· 9 9 import { setAuthToken } from "src/auth"; 10 10 import { pool } from "supabase/pool"; 11 11 import { supabaseServerClient } from "supabase/serverClient"; 12 + import { LeafletConfirmEmail } from "emails/leafletConfirmEmail"; 13 + import { sendConfirmationEmail } from "src/utils/confirmationEmail"; 12 14 13 15 async function sendAuthCode(email: string, code: string) { 14 - if (process.env.NODE_ENV === "development") { 15 - console.log("Auth code:", code); 16 - return; 17 - } 18 - 19 - let res = await fetch("https://api.postmarkapp.com/email", { 20 - method: "POST", 21 - headers: { 22 - "Content-Type": "application/json", 23 - "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 24 - }, 25 - body: JSON.stringify({ 26 - From: "Leaflet <accounts@leaflet.pub>", 27 - Subject: `Your authentication code for Leaflet is ${code}`, 28 - To: email, 29 - TextBody: `Paste this code to login to Leaflet: 30 - 31 - ${code} 32 - `, 33 - HtmlBody: ` 34 - <html> 35 - <body> 36 - <p>Paste this code to login to Leaflet: <strong>${code}</strong></p> 37 - </body> 38 - </html> 39 - `, 40 - }), 16 + await sendConfirmationEmail({ 17 + to: email, 18 + subject: `Your authentication code for Leaflet is ${code}`, 19 + template: ( 20 + <LeafletConfirmEmail 21 + code={code} 22 + title="Sign in to Leaflet" 23 + message="Paste this code to sign in" 24 + assetsBaseUrl={process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"} 25 + /> 26 + ), 27 + text: `Paste this code to login to Leaflet:\n\n${code}\n`, 28 + devLogTag: "auth code", 29 + code, 41 30 }); 42 31 } 43 32
+24 -3
app/api/inngest/functions/send_post_broadcast.ts
··· 45 45 async ({ event, step }) => { 46 46 const { publication_uri, document_uri } = event.data; 47 47 48 + const authorDid = new AtUri(document_uri).host; 49 + 48 50 const loaded = await step.run("load-pub-and-doc", async () => { 49 - const [pubRes, docRes] = await Promise.all([ 51 + const [pubRes, docRes, profileRes] = await Promise.all([ 50 52 supabaseServerClient 51 53 .from("publications") 52 54 .select( ··· 59 61 .select("data") 60 62 .eq("uri", document_uri) 61 63 .maybeSingle(), 64 + supabaseServerClient 65 + .from("bsky_profiles") 66 + .select("handle") 67 + .eq("did", authorDid) 68 + .maybeSingle(), 62 69 ]); 63 - return { pub: pubRes.data, doc: docRes.data }; 70 + return { 71 + pub: pubRes.data, 72 + doc: docRes.data, 73 + profile: profileRes.data, 74 + }; 64 75 }); 65 76 66 77 const settings = loaded.pub?.publication_newsletter_settings; ··· 108 119 } 109 120 const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); 110 121 const replyToEmail = resolveReplyToEmail(settings); 111 - const did = new AtUri(document_uri).host; 122 + const did = authorDid; 123 + const authorName = loaded.profile?.handle ?? undefined; 124 + const publishedAtLabel = docRecord?.publishedAt 125 + ? new Date(docRecord.publishedAt).toLocaleDateString("en-US", { 126 + month: "short", 127 + day: "numeric", 128 + year: "numeric", 129 + }) 130 + : undefined; 112 131 113 132 // The first page is the document body. Canvas pages don't map to a linear 114 133 // email body — the email renders an empty postContent section and falls ··· 160 179 postTitle, 161 180 postDescription, 162 181 postUrl, 182 + authorName, 183 + publishedAtLabel, 163 184 blocks, 164 185 did, 165 186 assetsBaseUrl: `${assetsBaseUrl}/`,
+6 -2
emails/leafletConfirmEmail.tsx
··· 17 17 export const LeafletConfirmEmail = (props: { 18 18 code?: string; 19 19 assetsBaseUrl?: string; 20 + title?: string; 21 + message?: string; 20 22 }) => { 21 23 const leafletSrc = makeStaticUrl( 22 24 props.assetsBaseUrl ?? "https://leaflet.pub", 23 25 )("leaflet.png"); 26 + const title = props.title ?? "Welcome to Leaflet!"; 27 + const message = props.message ?? "Verify your email with this code"; 24 28 return ( 25 29 <Html> 26 30 <Tailwind config={confirmEmailTailwindConfig}> ··· 42 46 43 47 <Column> 44 48 <Heading noPadding as="h2"> 45 - Welcome to Leaflet!{" "} 49 + {title}{" "} 46 50 </Heading> 47 51 </Column> 48 52 </Row> 49 53 <Text className="!mt-6 !mb-1 text-secondary"> 50 - Verify your email with this code 54 + {message} 51 55 </Text> 52 56 <Container className="bg-border-light rounded-md w-fit px-2 py-1"> 53 57 <Heading noPadding as="h1">
+139 -22
emails/post.tsx
··· 16 16 import React, { type CSSProperties } from "react"; 17 17 import { 18 18 PubLeafletBlocksBlockquote, 19 + PubLeafletBlocksButton, 19 20 PubLeafletBlocksCode, 20 21 PubLeafletBlocksHeader, 21 22 PubLeafletBlocksHorizontalRule, ··· 161 162 }, 162 163 { 163 164 $type: "pub.leaflet.pages.linearDocument#block", 165 + block: { 166 + $type: "pub.leaflet.blocks.button", 167 + text: "Click me", 168 + url: "https://leaflet.pub", 169 + }, 170 + }, 171 + { 172 + $type: "pub.leaflet.pages.linearDocument#block", 173 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignLeft", 174 + block: { 175 + $type: "pub.leaflet.blocks.button", 176 + text: "Aligned left", 177 + url: "https://leaflet.pub", 178 + }, 179 + }, 180 + { 181 + $type: "pub.leaflet.pages.linearDocument#block", 182 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignRight", 183 + block: { 184 + $type: "pub.leaflet.blocks.button", 185 + text: "Aligned right", 186 + url: "https://leaflet.pub", 187 + }, 188 + }, 189 + { 190 + $type: "pub.leaflet.pages.linearDocument#block", 164 191 block: { $type: "pub.leaflet.blocks.horizontalRule" }, 165 192 }, 166 193 { ··· 301 328 margin: "8px 0 0", 302 329 }} 303 330 > 304 - {p.postTitle} 331 + <Link 332 + href={p.postUrl} 333 + style={{ 334 + color: theme.primary, 335 + fontFamily: theme.headingFont, 336 + textDecoration: "none", 337 + }} 338 + > 339 + {p.postTitle} 340 + </Link> 305 341 </ReactEmailHeading> 306 342 307 343 {p.postDescription ? ( ··· 365 401 /> 366 402 </Link> 367 403 </Column> 368 - <Column style={{ width: 10 }} /> 369 - <Column style={{ width: 16, verticalAlign: "middle" }}> 370 - <Link href={p.postUrl} style={accentLink}> 371 - <Img 372 - width={16} 373 - height={16} 374 - src={staticUrl("external-link.png")} 375 - alt="Open post" 376 - /> 377 - </Link> 378 - </Column> 379 404 </Row> 380 405 </Section> 381 406 ) : null} ··· 384 409 <BlockRenderer 385 410 key={i} 386 411 block={b.block} 412 + alignment={b.alignment} 387 413 did={p.did} 388 414 assetsBaseUrl={p.assetsBaseUrl} 389 415 theme={theme} ··· 416 442 lineHeight: "20px", 417 443 }} 418 444 > 419 - See Full Post 445 + Read in Browser 420 446 </Link> 421 447 </td> 422 448 </tr> ··· 450 476 )} 451 477 </td> 452 478 </tr> 453 - </tbody> 454 - </table> 455 - </td> 456 - </tr> 457 479 458 480 {/* Spacer */} 459 481 <tr> 460 482 <td 461 483 style={{ 462 484 fontSize: 0, 463 - height: 12, 464 - lineHeight: "12px", 485 + height: 16, 486 + lineHeight: "16px", 465 487 }} 466 488 > 467 489 &nbsp; 468 490 </td> 469 491 </tr> 470 492 471 - {/* Horizontal rule between card and watermark. <hr> 472 - margins are flaky in Gmail, so we use a 1px-tall 473 - <td> with border-top instead. */} 493 + {/* Horizontal rule above watermark. <hr> margins are 494 + flaky in Gmail, so we use a 1px-tall <td> with 495 + border-top instead. */} 474 496 <tr> 475 497 <td 476 498 style={{ ··· 506 528 </tr> 507 529 </tbody> 508 530 </table> 531 + </td> 532 + </tr> 533 + </tbody> 534 + </table> 509 535 </td> 510 536 </tr> 511 537 </tbody> ··· 516 542 }; 517 543 export default PostEmail; 518 544 545 + // Map the lexicon's alignment token to the simple left/center/right values 546 + // usable as HTML `align` attributes. `justify` falls through to `left` to 547 + // match published web behavior (`justify-start` flex). For buttons we 548 + // default to `center` when alignment is unset, matching PostContent.tsx. 549 + const resolveButtonAlignment = ( 550 + alignment: string | undefined, 551 + ): "left" | "center" | "right" => { 552 + switch (alignment) { 553 + case "lex:pub.leaflet.pages.linearDocument#textAlignRight": 554 + return "right"; 555 + case "lex:pub.leaflet.pages.linearDocument#textAlignLeft": 556 + case "lex:pub.leaflet.pages.linearDocument#textAlignJustify": 557 + return "left"; 558 + case "lex:pub.leaflet.pages.linearDocument#textAlignCenter": 559 + default: 560 + return "center"; 561 + } 562 + }; 563 + 519 564 const BlockRenderer = ({ 520 565 block, 566 + alignment, 521 567 did, 522 568 assetsBaseUrl, 523 569 theme, 524 570 colors, 525 571 }: { 526 572 block: PubLeafletPagesLinearDocument.Block["block"]; 573 + alignment?: string; 527 574 did: string; 528 575 assetsBaseUrl: string; 529 576 theme: EmailTheme; ··· 630 677 previewSrc={previewSrc} 631 678 theme={theme} 632 679 colors={colors} 680 + /> 681 + ); 682 + } 683 + if (PubLeafletBlocksButton.isMain(block)) { 684 + return ( 685 + <ButtonBlock 686 + text={block.text} 687 + url={block.url} 688 + align={resolveButtonAlignment(alignment)} 689 + theme={theme} 633 690 /> 634 691 ); 635 692 } ··· 910 967 </Column> 911 968 ) : null} 912 969 </Row> 970 + </Section> 971 + ); 972 + }; 973 + 974 + export const ButtonBlock = ({ 975 + text, 976 + url, 977 + align = "center", 978 + theme = defaultEmailTheme, 979 + }: { 980 + text: string; 981 + url: string; 982 + align?: "left" | "center" | "right"; 983 + theme?: EmailTheme; 984 + }) => { 985 + // Bulletproof button: table-based so Outlook (which ignores padding on 986 + // <a>) renders a real clickable button. The `<td>` carries the bgcolor 987 + // attribute and padding; the `<a>` is `display: block` so the entire 988 + // padded area is clickable. Alignment via the table's `align` HTML 989 + // attribute — Gmail won't reliably cascade `text-align` from a wrapping 990 + // <Section>, so we anchor on the table itself. 991 + return ( 992 + <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> 993 + <table 994 + role="presentation" 995 + align={align} 996 + cellPadding={0} 997 + cellSpacing={0} 998 + border={0} 999 + style={{ borderCollapse: "separate" }} 1000 + > 1001 + <tbody> 1002 + <tr> 1003 + <td 1004 + align="center" 1005 + {...bgcolorAttr(theme.accentBackground)} 1006 + style={{ 1007 + backgroundColor: theme.accentBackground, 1008 + borderRadius: 6, 1009 + padding: "10px 20px", 1010 + }} 1011 + > 1012 + <Link 1013 + href={url} 1014 + style={{ 1015 + color: theme.accentText, 1016 + display: "block", 1017 + fontFamily: theme.bodyFont, 1018 + fontSize: 16, 1019 + fontWeight: "bold", 1020 + lineHeight: "20px", 1021 + textDecoration: "none", 1022 + }} 1023 + > 1024 + {text} 1025 + </Link> 1026 + </td> 1027 + </tr> 1028 + </tbody> 1029 + </table> 913 1030 </Section> 914 1031 ); 915 1032 };