From 3abdac3c4c09068a97cd472e68070fed7e600302 Mon Sep 17 00:00:00 2001 From: "Claude (GTM agent)" Date: Tue, 1 Sep 2026 08:29:42 +0200 Subject: [PATCH 1/2] Define account setup service email as its own narrow category Q31a, option b restricted, approved by the founder. The first two activation emails need a basis that gives them recipients: opt-in consent to product_communications is 0 of 8, so the sequence would send nothing. .agents/product-marketing.md drops "activation guidance" from the product_communications topic and defines account setup service email: welcome and first skill only, to the team creator, inside 14 days of team creation, 3 proactive emails per person ever, visible unsubscribe, every suppression honored. The founder-voice sender rule now says the opt-in check is replaced by qualification under that category for service email only, and that suppression, unsubscribe, identity, provider, a real public postal identity, and explicit send authorization stay mandatory for it too. docs/email-compliance.md documents the operational contract: perimeter, window, legal basis, frequency caps, permanent exit conditions, suppression precedence, reuse of the existing one-click unsubscribe, emailSubscriber exclusion, no retroactive send, and the two preconditions on the first send. No code changes. No send is enabled. The product_communications disclosure and its notice version are untouched. --- .agents/product-marketing.md | 5 +++-- docs/email-compliance.md | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.agents/product-marketing.md b/.agents/product-marketing.md index 5d70713f..ae45eac9 100644 --- a/.agents/product-marketing.md +++ b/.agents/product-marketing.md @@ -177,8 +177,9 @@ Two facts license the wording, and neither is a new claim: - Publish only verified, already shipped product reality. A capability claim requires the current production deployment and product readback to prove it. Never turn a hypothesis, draft, experiment, private prototype, or unmerged pull request into a public capability claim. - Do not invent founder anecdotes, customer stories, personal reading, roadmap commitments, timelines, or ETAs. First-person copy may describe only documented actions, motivations, and results. - The connected personal social account may discuss only Skills Board and its shipped features. LinkedIn copy is Italian; X and every email category are English. -- Proactive founder-voice email uses `Tommaso from Skills Board ` only after consent, suppression, unsubscribe, identity, provider, public-postal-identity, and explicit send authorization are verified. Transactional email keeps a separate operational identity. -- Product updates, newsletters, activation guidance, and structured product-research invitations share the `product_communications` consent topic. Optional in-app surveys follow their own non-blocking research/opt-out contract and do not require that email consent. The topic never authorizes personal qualitative follow-up. +- Proactive founder-voice email uses `Tommaso from Skills Board ` only after consent, suppression, unsubscribe, identity, provider, public-postal-identity, and explicit send authorization are verified. Account setup service email is the one category where the opt-in consent check is replaced by qualification under the service category defined below. Suppression, unsubscribe, identity, provider, a real public postal identity, and explicit send authorization stay mandatory for it too. Transactional email keeps a separate operational identity. +- Product updates, newsletters, and structured product-research invitations share the `product_communications` consent topic. Optional in-app surveys follow their own non-blocking research/opt-out contract and do not require that email consent. The topic never authorizes personal qualitative follow-up. +- Account setup service email is a separate, narrow category: only the welcome message and the first skill reminder, sent to the team creator within 14 days of team creation, capped at 3 proactive emails per person ever, each carrying a visible unsubscribe, with every suppression honored. Its basis is the setup of the account the user just created, not marketing consent. Later activation messages, such as inviting a teammate or connecting an agent, stay under `product_communications` and require opt-in consent. Service email never becomes a newsletter, a digest, or a re-engagement channel, and it never goes to `emailSubscriber` addresses. - Paid advertising is policy-ineligible while Skills Board has no revenue line. Reconsidering it requires a merged strategy change that establishes a revenue model. - Public proof points remain unavailable until the underlying metric is production-validated or the attributable person or customer has granted publication consent. A public metric also requires its exact definition, window, denominator, privacy-safe aggregation, and authorization under the policy that owns it. - A material change to the product contract, ICP, JTBD, positioning, or durable communication policy is delivered through the normal repository review process. diff --git a/docs/email-compliance.md b/docs/email-compliance.md index 3902f4e0..ab27c8c2 100644 --- a/docs/email-compliance.md +++ b/docs/email-compliance.md @@ -21,6 +21,22 @@ Proactive sender identity: `Tommaso from Skills Board ` +## Activation service email + +Activation service email is a third category, separate from `product_communications` marketing and from sign-in codes and team invitations. It covers exactly two messages, `activation_welcome` and `activation_first_skill`, sent only to the person who created a team. Nothing else ever enters the category. + +- Perimeter: those two message types and no others. Inviting a teammate and connecting an agent are later activation messages that stay under the `product_communications` opt-in topic, without exception. +- Window: a person is eligible only within 14 days of team creation. On day 14 the sequence ends for that person, whatever has been sent. +- Legal basis: in the EU the basis is legitimate interest toward a registered user, limited to completing the setup of the account that user just created, and it is never marketing consent. In the United States the messages carry the CAN SPAM footer with a working opt out. Both bases are limited to the perimeter above. +- Frequency: at most one activation email per user per day, at least 24 hours between two emails of the sequence, and no more than 3 activation emails to any person, ever. The count is per person and never resets. The sequence defines two messages; 3 is the ceiling that no later change may exceed without its own review. +- Permanent exit: withdrawn consent, a suppression of any kind, the completed action the message asks for, the end of the 14-day window, or 3 activation emails already sent. Any one of these ends the sequence for that person for good. +- Suppression wins. An `emailSuppression` row of either scope blocks an activation send before it reaches Resend, exactly as it blocks a marketing send, and no activation rule may relax it. +- Unsubscribe: the sequence reuses the existing public, encrypted, idempotent one-click unsubscribe and its RFC 8058 headers. It adds no second mechanism and no separate token. +- The `emailSubscriber` list is out of scope. Those rows are visitor captures with no account and no team, so they never receive an activation email. +- No retroactive send. Users and teams created before the sequence ships are not backfilled into it, and doing so would need its own recorded decision. + +Two preconditions gate the first send of the sequence. The footer must carry a real public postal identity: the value in `lib/site.ts`, quoted above, is a placeholder and has to be replaced with the operator's real address before any activation email leaves. And the founder has to authorize that first send explicitly, as with every other proactive category. + ## Data model - `emailPreference` holds the current topic choice, notice version and text, email hash, stable encrypted unsubscribe token, and timestamps. From 4d951ef87527c673afb46ec0ee53302b2f0eb09b Mon Sep 17 00:00:00 2001 From: "Claude (GTM agent)" Date: Tue, 1 Sep 2026 21:17:30 +0200 Subject: [PATCH 2/2] Drop the placeholder postal address and scope the postal requirement to marketing Q31d, approved by the maintainer. The CAN SPAM postal address requirement covers commercial messages. Account setup service email and transactional email are relationship messages, so they do not need one, and the address that was in `lib/site.ts` was invented: "15 Giuseppe Verdi Avenue, Suite 150, Capraia Innovation Park, CA 50050" is not a real place. A fabricated address in an email footer, on the terms and privacy pages, and inside the Organization JSON-LD is worse than no address at all. `postalAddress` and its structured twin `address` are now nullable and set to null. Every surface that printed them omits the block while they are unset: the legal page footer, the marketing email footer, the terms and privacy contact paragraphs, the contact page card, and the Organization JSON-LD, which now emits `address` only when there is a real one. Setting the two fields together, with a real address, restores every surface at once. No address was invented to replace the old one. docs/email-compliance.md now states that a marketing broadcast carries the public postal address and that service and transactional email do not, and that the address is required before the first broadcast to the opt-in list rather than before the first activation email. `.agents/product-marketing.md` is aligned on the same point for the service category. The Codex P2 on this PR is addressed in the same paragraph: the first send of the sequence is now gated on the public privacy notice describing this category and its basis, plus the explicit send authorization that was already there. Publishing a notice and enabling a send both stay manual. The organization node test asserts the address is absent while the two spellings are unset, that neither is ever set alone, and keeps the agreement check for when a real address exists. tsc --noEmit clean. 562 unit tests pass. No send is enabled. --- .agents/product-marketing.md | 2 +- app/contact/page.tsx | 22 +++++++------ app/privacy/page.tsx | 6 ++-- app/terms/page.tsx | 5 +-- components/legal-page-shell.tsx | 8 +++-- docs/email-compliance.md | 10 +++--- emails/components/marketing-footer.tsx | 9 ++++-- lib/seo/organization.ts | 17 +++++----- lib/site.ts | 43 +++++++++++++++++--------- tests/developer-surface.test.mjs | 16 ++++++++-- 10 files changed, 89 insertions(+), 49 deletions(-) diff --git a/.agents/product-marketing.md b/.agents/product-marketing.md index ae45eac9..e53b5be5 100644 --- a/.agents/product-marketing.md +++ b/.agents/product-marketing.md @@ -177,7 +177,7 @@ Two facts license the wording, and neither is a new claim: - Publish only verified, already shipped product reality. A capability claim requires the current production deployment and product readback to prove it. Never turn a hypothesis, draft, experiment, private prototype, or unmerged pull request into a public capability claim. - Do not invent founder anecdotes, customer stories, personal reading, roadmap commitments, timelines, or ETAs. First-person copy may describe only documented actions, motivations, and results. - The connected personal social account may discuss only Skills Board and its shipped features. LinkedIn copy is Italian; X and every email category are English. -- Proactive founder-voice email uses `Tommaso from Skills Board ` only after consent, suppression, unsubscribe, identity, provider, public-postal-identity, and explicit send authorization are verified. Account setup service email is the one category where the opt-in consent check is replaced by qualification under the service category defined below. Suppression, unsubscribe, identity, provider, a real public postal identity, and explicit send authorization stay mandatory for it too. Transactional email keeps a separate operational identity. +- Proactive founder-voice email uses `Tommaso from Skills Board ` only after consent, suppression, unsubscribe, identity, provider, public-postal-identity, and explicit send authorization are verified. Account setup service email is the one category where the opt-in consent check is replaced by qualification under the service category defined below. Suppression, unsubscribe, identity, provider, and explicit send authorization stay mandatory for it too. A public postal identity is not required for that category, because the CAN SPAM postal rule covers commercial messages and service email is a relationship message; it stays required for marketing broadcasts. Transactional email keeps a separate operational identity. - Product updates, newsletters, and structured product-research invitations share the `product_communications` consent topic. Optional in-app surveys follow their own non-blocking research/opt-out contract and do not require that email consent. The topic never authorizes personal qualitative follow-up. - Account setup service email is a separate, narrow category: only the welcome message and the first skill reminder, sent to the team creator within 14 days of team creation, capped at 3 proactive emails per person ever, each carrying a visible unsubscribe, with every suppression honored. Its basis is the setup of the account the user just created, not marketing consent. Later activation messages, such as inviting a teammate or connecting an agent, stay under `product_communications` and require opt-in consent. Service email never becomes a newsletter, a digest, or a re-engagement channel, and it never goes to `emailSubscriber` addresses. - Paid advertising is policy-ineligible while Skills Board has no revenue line. Reconsidering it requires a merged strategy change that establishes a revenue model. diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 305fb397..6206f91e 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -51,17 +51,19 @@ export default function ContactPage() { -
-
- - -
-

Postal address

-
{siteConfig.postalAddress}
+ {siteConfig.postalAddress ? ( +
+
+ + +
+

Postal address

+
{siteConfig.postalAddress}
+
-
-
+ + ) : null}

Public bug reports and feature requests

diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index f0979ecc..011ff7dc 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -44,8 +44,8 @@ export default function PrivacyPage() {

Controller and contact

The hosted service is operated under the Skills Board name. Skills Board is the controller for the personal - data described in this policy. Contact {siteConfig.contactEmail} or write to {siteConfig.name},{" "} - {siteConfig.postalAddress}. + data described in this policy. Contact {siteConfig.contactEmail} + {siteConfig.postalAddress ? ` or write to ${siteConfig.name}, ${siteConfig.postalAddress}` : ""}.

@@ -172,7 +172,7 @@ export default function PrivacyPage() {

Contact

For privacy questions or requests, email {siteConfig.contactEmail} - {" "}or write to {siteConfig.name}, {siteConfig.postalAddress}. + {siteConfig.postalAddress ? ` or write to ${siteConfig.name}, ${siteConfig.postalAddress}` : ""}.

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 98881177..90399168 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -43,7 +43,8 @@ export default function TermsPage() {

Service operator

The hosted service is operated under the Skills Board name. The operator can be reached at{" "} - {siteConfig.contactEmail} or at {siteConfig.postalAddress}. + {siteConfig.contactEmail} + {siteConfig.postalAddress ? ` or at ${siteConfig.postalAddress}` : ""}.

@@ -163,7 +164,7 @@ export default function TermsPage() {

Contact

Questions about these terms can be sent to {siteConfig.contactEmail} - {" "}or mailed to {siteConfig.name}, {siteConfig.postalAddress}. + {siteConfig.postalAddress ? ` or mailed to ${siteConfig.name}, ${siteConfig.postalAddress}` : ""}.

diff --git a/components/legal-page-shell.tsx b/components/legal-page-shell.tsx index ac95e126..7abaa733 100644 --- a/components/legal-page-shell.tsx +++ b/components/legal-page-shell.tsx @@ -72,9 +72,11 @@ export function LegalPageShell({ {siteConfig.contactEmail}

-
- {siteConfig.postalAddress} -
+ {siteConfig.postalAddress ? ( +
+ {siteConfig.postalAddress} +
+ ) : null} diff --git a/docs/email-compliance.md b/docs/email-compliance.md index ab27c8c2..30842a4b 100644 --- a/docs/email-compliance.md +++ b/docs/email-compliance.md @@ -9,13 +9,13 @@ Skills Board keeps product communications separate from sign-in codes and team i - For a newly created account, leaving the signup choice unchecked records a non-consent default after verification. It never grants consent or changes an existing account's preference. - Existing accounts with no recorded choice see one inline, non-blocking choice in the authenticated product until they answer. - Authenticated users can grant or withdraw consent at `/settings/email`. -- Every product email includes a visible unsubscribe link, a preferences link, the sender identity, and the exact postal address below. +- Every product email includes a visible unsubscribe link, a preferences link, and the sender identity. A marketing broadcast also carries the public postal address, because the CAN SPAM postal requirement applies to commercial messages. Account setup service email and transactional email are relationship messages and carry no address. - One-click unsubscribe is public, encrypted, idempotent, and does not expose the raw email address or a stable user identifier. - Product campaigns use the verified consent and suppression contract without adding an account-age, weekly, or other arbitrary attention gate. Public postal identity: -`15 Giuseppe Verdi Avenue, Suite 150, Capraia Innovation Park, CA 50050` +There is none yet. `postalAddress` and `address` in `lib/site.ts` are `null`, and every surface that would print an address omits it while they are. Set both, with the operator's real address, before the first marketing broadcast. Never fill them with a placeholder: a made up address is worse than no address, in an email and on a legal page alike. Proactive sender identity: @@ -35,7 +35,9 @@ Activation service email is a third category, separate from `product_communicati - The `emailSubscriber` list is out of scope. Those rows are visitor captures with no account and no team, so they never receive an activation email. - No retroactive send. Users and teams created before the sequence ships are not backfilled into it, and doing so would need its own recorded decision. -Two preconditions gate the first send of the sequence. The footer must carry a real public postal identity: the value in `lib/site.ts`, quoted above, is a placeholder and has to be replaced with the operator's real address before any activation email leaves. And the founder has to authorize that first send explicitly, as with every other proactive category. +A public postal address is not a precondition for this category. The CAN SPAM postal requirement covers commercial messages, and account setup service email is relationship email, so its footer carries the sender identity and a working unsubscribe and no address. A real postal address stays required before any future marketing broadcast to the opt-in list, such as a newsletter or a product update. Approved by the maintainer on 2026-09-01. + +Two preconditions gate the first send of the sequence. The public privacy notice has to describe this category and the basis it relies on before the category is switched on: the notice published today says product communications go out only after opt-in, so enabling the sequence without that update would use the provider for a purpose users have not been told about. And the founder has to authorize that first send explicitly, as with every other proactive category. ## Data model @@ -69,7 +71,7 @@ Provider resources must be created and read back through the official authentica 5. Render the local Skills Board unsubscribe link from that contact property and retain Resend's native `{{{RESEND_UNSUBSCRIBE_URL}}}`. The local link updates the canonical database; the native link keeps Resend more restrictive even if topic-level state is not present in `contact.updated` webhooks. 6. Keep the draft unsent until sender, segment, topic, suppression, unsubscribe, postal identity, quota, duplicate effect, and audience readbacks all match. -The React Email source for the approved launch body is `emails/product-launch.tsx`. Its footer uses the exact public postal identity from `lib/site.ts`. +The React Email source for the approved launch body is `emails/product-launch.tsx`. Its footer prints the public postal identity from `lib/site.ts` when one is set, so that address has to exist before the broadcast goes out. ## Transactional delivery diff --git a/emails/components/marketing-footer.tsx b/emails/components/marketing-footer.tsx index 5755bb6b..5ae6112d 100644 --- a/emails/components/marketing-footer.tsx +++ b/emails/components/marketing-footer.tsx @@ -28,8 +28,13 @@ export function MarketingFooter({ Contact Skills Board - {siteConfig.name}
- {siteConfig.postalAddress} + {siteConfig.name} + {siteConfig.postalAddress ? ( + <> +
+ {siteConfig.postalAddress} + + ) : null}
) diff --git a/lib/seo/organization.ts b/lib/seo/organization.ts index a1442410..8c0255f6 100644 --- a/lib/seo/organization.ts +++ b/lib/seo/organization.ts @@ -14,12 +14,13 @@ const organizationLogoUrl = absoluteUrl("/apple-icon.png") * assistant answering "who runs Skills Board, and how do I reach them" reads * whichever page it happened to fetch, so the answer depended on the page. * - * `contactPoint` and `address` are both here because schema.org treats them as - * the two halves of a reachable organization, and a consumer checking whether a - * business is real looks for both. + * `contactPoint` and `address` are the two halves schema.org uses for a + * reachable organization, and a consumer checking whether a business is real + * looks for both. The address is emitted only when `lib/site.ts` holds a real + * one: a fabricated address would answer the same question with a lie. */ export function organizationNode() { - return { + const node = { "@type": "Organization", "@id": organizationId, name: siteConfig.name, @@ -34,11 +35,11 @@ export function organizationNode() { url: absoluteUrl("/contact"), availableLanguage: "English", }, - address: { - "@type": "PostalAddress", - ...siteConfig.address, - }, } + + if (!siteConfig.address) return node + + return { ...node, address: { "@type": "PostalAddress", ...siteConfig.address } } } /** The logo the organization node references, as its own addressable node. */ diff --git a/lib/site.ts b/lib/site.ts index 2ee51c29..df1f014b 100644 --- a/lib/site.ts +++ b/lib/site.ts @@ -1,3 +1,12 @@ +/** The parts of a `PostalAddress`, spelled as schema.org names them. */ +type PostalAddressParts = { + streetAddress: string + addressLocality: string + addressRegion: string + postalCode: string + addressCountry: string +} + export const siteConfig = { name: "Skills Board", url: "https://www.skillsboard.sh", @@ -8,22 +17,28 @@ export const siteConfig = { "The agent-native skills registry for teams. Keep your team's AI skills in one place, and reach them from your agent through MCP.", githubUrl: "https://github.com/TommyBez/skillsboard", contactEmail: "tommaso@skillsboard.sh", - postalAddress: "15 Giuseppe Verdi Avenue, Suite 150, Capraia Innovation Park, CA 50050", /** - * The same postal address in its parts, for the `PostalAddress` in the - * Organization JSON-LD. Written out rather than parsed back out of the - * one-line spelling above: an address is not reliably recoverable from its - * own comma separated form, and a structured address is what lets an agent - * answer "where is this company" without guessing which comma meant what. - * The `mailingAddress` test asserts the two spellings agree. + * The public postal identity, in one line for prose and in parts for the + * `PostalAddress` in the Organization JSON-LD. Both are `null` while there + * is no real address to publish, and every surface omits the address while + * they are. + * + * The CAN SPAM postal address requirement covers commercial messages. The + * email Skills Board sends today is account setup service email and + * transactional email, both relationship messages, so no address is needed + * to start. A made up address is worse than none, so nothing here is ever + * filled with a placeholder. Set both together, with the operator's real + * address, before any marketing broadcast goes out. + * + * The parts are written out rather than parsed back out of the one-line + * spelling: an address is not reliably recoverable from its own comma + * separated form, and a structured address is what lets an agent answer + * "where is this company" without guessing which comma meant what. The + * organization node test asserts the two spellings agree whenever they are + * set, and that neither is set alone. */ - address: { - streetAddress: "15 Giuseppe Verdi Avenue, Suite 150", - addressLocality: "Capraia Innovation Park", - addressRegion: "CA", - postalCode: "50050", - addressCountry: "US", - }, + postalAddress: null as string | null, + address: null as PostalAddressParts | null, locale: "en_US", } as const diff --git a/tests/developer-surface.test.mjs b/tests/developer-surface.test.mjs index c854ef17..01d92ed5 100644 --- a/tests/developer-surface.test.mjs +++ b/tests/developer-surface.test.mjs @@ -218,10 +218,10 @@ test("the developer docs schema describes the page as technical documentation", assert.equal(article.encoding.contentUrl, `${siteConfig.url}${developersPath}.md`) const organization = graph.find((node) => node["@type"] === "Organization") - assert.equal(organization.address["@type"], "PostalAddress") + assert.ok(organization, "no Organization node") }) -test("the organization node states both a contact point and an address", async () => { +test("the organization node states a contact point, and an address only when there is one", async () => { const { buildLandingSchema } = await import("../lib/seo/landing-schema.ts") const { organizationNode } = await import("../lib/seo/organization.ts") @@ -232,6 +232,18 @@ test("the organization node states both a contact point and an address", async ( const organization = graph.find((node) => node["@type"] === "Organization") assert.ok(organization.contactPoint?.email, "the organization has no contact point") + + // The postal identity is optional and is null today: CAN SPAM asks for a + // postal address on commercial messages, and there is no real one to + // publish yet. A placeholder would answer "where is this company" with a + // lie, so the node carries no address until both spellings are set. + if (!siteConfig.address || !siteConfig.postalAddress) { + assert.equal(siteConfig.address, null, "one spelling of the mailing address is set without the other") + assert.equal(siteConfig.postalAddress, null, "one spelling of the mailing address is set without the other") + assert.equal(organization.address, undefined, "the organization publishes an address it does not have") + continue + } + assert.equal(organization.address["@type"], "PostalAddress") assert.equal(organization.address.addressCountry, siteConfig.address.addressCountry) // The one-line spelling used in the footer and in email has to be the same