Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/self-hosted/hosting/nginx-multi-tenant.conf
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ server {
# Per-tenant static SEO files, written by the hosting API's sync pass.
# robots falls back to the generic file baked in the image; sitemap and
# rss simply 404 until the first pass writes them.
# Newsletter subscribe (vision-web#1537): the blog's signup form posts here,
# same-origin, and this forwards to ecency.com's public relay. Managed
# instances only, by construction: only they sit behind this nginx. Only
# the subscribe POST is exposed; confirmation and unsubscribe links in the
# mail point at ecency.com directly.
location = /api/newsletter/subscribe {
proxy_pass https://ecency.com/api/newsletter/subscribe;
proxy_ssl_server_name on;
proxy_set_header Host ecency.com;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
proxy_read_timeout 15s;
client_max_body_size 16k;
}

location = /robots.txt {
try_files /configs/$tenant_id.robots.txt /robots.txt =404;
}
Expand Down Expand Up @@ -210,6 +224,20 @@ server {
}

# Same static SEO files, keyed by custom tenant.
# Newsletter subscribe (vision-web#1537): the blog's signup form posts here,
# same-origin, and this forwards to ecency.com's public relay. Managed
# instances only, by construction: only they sit behind this nginx. Only
# the subscribe POST is exposed; confirmation and unsubscribe links in the
# mail point at ecency.com directly.
location = /api/newsletter/subscribe {
proxy_pass https://ecency.com/api/newsletter/subscribe;
proxy_ssl_server_name on;
proxy_set_header Host ecency.com;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 15s;
client_max_body_size 16k;
}

location = /robots.txt {
try_files /configs/$custom_tenant_id.robots.txt /robots.txt =404;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/self-hosted/src/core/configuration-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export interface InstanceConfig {
post: {
text2Speech: { enabled: boolean };
};
/** The email-digest signup form (managed instances; the form posts to the host's own /api/newsletter/subscribe). */
newsletter?: { enabled?: boolean };
tipping?: {
enabled?: boolean;
general?: { enabled: boolean; buttonLabel?: string };
Expand Down
26 changes: 25 additions & 1 deletion apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,19 @@ export type TranslationKey =
| 'panel_configuration_instance_configuration_features_hive_payout_label_description'
| 'panel_validation_learn_more_url'
| 'panel_validation_create_post_url_community'
| 'panel_validation_create_post_url_refused';
| 'panel_validation_create_post_url_refused'
| 'newsletterTitle'
| 'newsletterBlurb'
| 'newsletterCommunityBlurb'
| 'newsletterEmail'
| 'newsletterWeekly'
| 'newsletterMonthly'
| 'newsletterSubscribe'
| 'newsletterCheckInbox'
| 'newsletterError'
| 'panel_configuration_instance_configuration_features_newsletter_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_description';

export type Translations = Record<TranslationKey, string>;

Expand All @@ -323,6 +335,18 @@ export const translations: { en: Translations } & Record<
Partial<Translations>
> = {
en: {
panel_configuration_instance_configuration_features_newsletter_label: 'Newsletter signup',
panel_configuration_instance_configuration_features_newsletter_enabled_label: 'Show the email signup form',
panel_configuration_instance_configuration_features_newsletter_enabled_description: 'Readers can subscribe to a weekly or monthly email digest of this site (managed hosting only; double opt-in).',
newsletterTitle: 'Get new posts by email',
newsletterBlurb: 'A weekly or monthly digest of new posts. Double opt-in, unsubscribe any time.',
newsletterCommunityBlurb: 'The best of this community as a weekly or monthly digest. Double opt-in, unsubscribe any time.',
newsletterEmail: 'Your email',
newsletterWeekly: 'Weekly',
newsletterMonthly: 'Monthly',
newsletterSubscribe: 'Subscribe',
newsletterCheckInbox: 'Almost there: confirm from the email we just sent.',
newsletterError: 'Could not subscribe right now. Please try again.',
loading: "Loading...",
hivesigner_login_failed: 'Sign in could not be completed. Please try again.',
loadingPost: "Loading post...",
Expand Down
1 change: 1 addition & 0 deletions apps/self-hosted/src/features/blog/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './blog-post-item';
export * from './blog-post-page';
export * from './blog-posts-list';
export * from './detect-bottom';
export * from './newsletter-signup';
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { type FormEvent, useState } from 'react';
import { InstanceConfigManager } from '../../../core/configuration-loader';
import { t } from '../../../core/i18n';
import { newsletterSignupTarget, newsletterSubscribeBody } from '../utils/newsletter-signup-target';

/**
* The email-digest signup form (vision-web#1537). Managed instances only, by
* two fences: the form renders only when the served config carries `managed`
* (never on a true self-host or the unclaimed template), and it posts to the
* host's own `/api/newsletter/subscribe`, a path only the managed nginx
* forwards to ecency.com's public relay. Double opt-in end to end: the service
* answers `pending_confirmation` and the reader confirms from their inbox, so
* the form always says "check your inbox" on success and can learn nothing
* about an address it does not own.
*/
export function NewsletterSignup() {
const target = InstanceConfigManager.useConfig(({ configuration }) =>
newsletterSignupTarget({
username: configuration.instanceConfiguration.username,
managed: configuration.instanceConfiguration.managed,
template: configuration.instanceConfiguration.template,
enabled: configuration.instanceConfiguration.features.newsletter?.enabled ?? true,
siteTitle: configuration.instanceConfiguration.meta?.title,
})
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
);

const [email, setEmail] = useState('');
const [cadence, setCadence] = useState<'weekly' | 'monthly'>('weekly');
const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle');

if (!target) return null;
const isCommunity = target.type === 'community';

const submit = async (e: FormEvent) => {
e.preventDefault();
if (state === 'busy' || !email.trim()) return;
setState('busy');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newsletterSubscribeBody(target, email, cadence)),
});
setState(res.ok ? 'done' : 'error');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch {
setState('error');
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}
};

return (
<div className="border-t border-theme pt-4 mt-4 sidebar-newsletter-section" data-testid="newsletter-signup">
<h3 className="text-sm font-semibold mb-1">{t('newsletterTitle')}</h3>
<p className="text-xs text-theme-muted mb-2">{t(isCommunity ? 'newsletterCommunityBlurb' : 'newsletterBlurb')}</p>
{state === 'done' ? (
<p className="text-xs" role="status">
{t('newsletterCheckInbox')}
</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
) : (
<form onSubmit={submit} className="flex flex-col gap-2">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('newsletterEmail')}
aria-label={t('newsletterEmail')}
className="input-theme w-full text-sm px-2 py-1.5 rounded"
/>
<div className="flex gap-2">
<select
value={cadence}
onChange={(e) => setCadence(e.target.value as 'weekly' | 'monthly')}
aria-label={t('newsletterSubscribe')}
className="input-theme flex-1 text-sm px-2 py-1.5 rounded"
>
<option value="weekly">{t('newsletterWeekly')}</option>
<option value="monthly">{t('newsletterMonthly')}</option>
</select>
<button
type="submit"
disabled={state === 'busy'}
className="btn-theme-primary text-sm px-3 py-1.5 rounded disabled:opacity-60"
>
{t('newsletterSubscribe')}
</button>
</div>
{state === 'error' && (
<p className="text-xs text-red-500" role="alert">
{t('newsletterError')}
</p>
)}
</form>
)}
</div>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
5 changes: 4 additions & 1 deletion apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatMonthYear, InstanceConfigManager, t } from "@/core";
import { NewsletterSignup } from "../components/newsletter-signup";
import { useAuth } from "@/features/auth";
import { InlineError } from "@/features/shared/inline-error";
import {
Expand Down Expand Up @@ -103,6 +104,7 @@ function BlogSidebarContent({ username }: { username: string }) {
</div>
</div>
)}
<NewsletterSignup />
{data && (
<div className="border-t border-theme pt-4 mt-4 sidebar-hive-info-section">
<div className="text-xs font-medium mb-2 text-theme-muted">
Expand Down Expand Up @@ -317,7 +319,8 @@ function CommunitySidebar() {
<CommunityJoinButton communityId={communityId} />
</div>

<div className="border-t border-theme pt-4 mt-4 sidebar-hive-info-section">
<NewsletterSignup />
<div className="border-t border-theme pt-4 mt-4 sidebar-hive-info-section">
<div className="text-xs font-medium mb-2 text-theme-muted">
{t("community_info")}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { newsletterSignupTarget, newsletterSubscribeBody } from './newsletter-signup-target';

describe('newsletterSignupTarget', () => {
const managedBlog = { username: 'Alice', managed: true, siteTitle: 'Alice Writes' };

it('offers the form only on a managed, claimed instance with the feature on', () => {
expect(newsletterSignupTarget(managedBlog)).toEqual({ type: 'creator', target: 'alice', targetLabel: 'Alice Writes' });
// A true self-host never gets it: the config has no managed marker.
expect(newsletterSignupTarget({ username: 'alice' })).toBeNull();
expect(newsletterSignupTarget({ ...managedBlog, managed: undefined })).toBeNull();
// The unclaimed template, though served as managed, is not a tenant.
expect(newsletterSignupTarget({ ...managedBlog, template: true })).toBeNull();
// The owner's toggle.
expect(newsletterSignupTarget({ ...managedBlog, enabled: false })).toBeNull();
expect(newsletterSignupTarget({ ...managedBlog, enabled: true })).not.toBeNull();
expect(newsletterSignupTarget({ managed: true })).toBeNull();
});

it('a hive-… instance subscribes to the community digest; labels fall back to the handle', () => {
expect(newsletterSignupTarget({ username: 'hive-125125', managed: true, siteTitle: 'Town Square' })).toEqual({
type: 'community',
target: 'hive-125125',
targetLabel: 'Town Square',
});
expect(newsletterSignupTarget({ username: 'HIVE-125125', managed: true, siteTitle: ' ' })).toEqual({
type: 'community',
target: 'hive-125125',
targetLabel: 'hive-125125',
});
expect(newsletterSignupTarget({ username: 'bob', managed: true })).toEqual({ type: 'creator', target: 'bob', targetLabel: '@bob' });
});

it('builds the exact relay body, source self-hosted-blog', () => {
const t = newsletterSignupTarget(managedBlog)!;
expect(newsletterSubscribeBody(t, ' reader@example.com ', 'monthly')).toEqual({
email: 'reader@example.com',
type: 'creator',
target: 'alice',
targetLabel: 'Alice Writes',
cadence: 'monthly',
source: 'self-hosted-blog',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* The pure half of the newsletter signup form (vision-web#1537): whether this
* instance offers it and what a submission subscribes to. Kept out of the
* component so the rules are testable in the node test environment.
*
* Managed instances only: the form renders only when the served config carries
* `managed` (never a true self-host, never the unclaimed template), and its
* POST goes to the host's own /api/newsletter/subscribe, a path only the
* managed nginx forwards to ecency.com's public relay.
*/
export interface NewsletterSignupTarget {
type: 'creator' | 'community';
target: string;
targetLabel: string;
}

const COMMUNITY_RE = /^hive-\d+$/i;

export function newsletterSignupTarget(cfg: {
username?: string;
managed?: boolean;
template?: boolean;
enabled?: boolean;
siteTitle?: string;
}): NewsletterSignupTarget | null {
if (cfg.managed !== true || cfg.template === true || cfg.enabled === false || !cfg.username) return null;
const target = cfg.username.toLowerCase();
const isCommunity = COMMUNITY_RE.test(target);
return {
type: isCommunity ? 'community' : 'creator',
target,
targetLabel: cfg.siteTitle?.trim() || (isCommunity ? target : `@${target}`),
};
}

/** The body the form posts, exactly as the relay expects it. */
export function newsletterSubscribeBody(t: NewsletterSignupTarget, email: string, cadence: 'weekly' | 'monthly') {
return {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
email: email.trim(),
type: t.type,
target: t.target,
targetLabel: t.targetLabel,
cadence,
source: 'self-hosted-blog' as const,
};
}
11 changes: 11 additions & 0 deletions apps/self-hosted/src/features/floating-menu/config-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,17 @@ export function buildConfigFields(
type: 'array',
description: t('panel_configuration_instance_configuration_features_posts_filters_description'),
},
newsletter: {
label: t('panel_configuration_instance_configuration_features_newsletter_label'),
type: 'section',
fields: {
enabled: {
label: t('panel_configuration_instance_configuration_features_newsletter_enabled_label'),
type: 'boolean',
description: t('panel_configuration_instance_configuration_features_newsletter_enabled_description'),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
},
},
},
likes: {
label: t('panel_configuration_instance_configuration_features_likes_label'),
type: 'section',
Expand Down
16 changes: 5 additions & 11 deletions apps/web/src/app/api/newsletter/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { NextRequest } from "next/server";
import { resolveUser, unauthorizedResponse } from "@/app/api/threespeak/resolve-user";
import { isProRosterMember } from "@/server/pro-members";
import {
callNewsletter,
clientIp,
Expand All @@ -12,7 +11,7 @@ import {

const TYPES = new Set(["own", "community", "creator", "site"]);
const CADENCES = new Set(["weekly", "monthly"]);
const SOURCES = new Set(["community-page", "creator-page", "settings", "landing-page", "publish-prompt", "post-page"]);
const SOURCES = new Set(["community-page", "creator-page", "settings", "landing-page", "publish-prompt", "post-page", "self-hosted-blog"]);

/**
* Subscribe to a community or creator digest.
Expand Down Expand Up @@ -58,15 +57,10 @@ export async function POST(request: NextRequest) {
if (target !== account) return Response.json({ error: "Invalid request" }, { status: 400 });
}

if (type === "creator") {
const isPro = await isProRosterMember(target);
if (isPro === null) {
return Response.json({ error: "Authorization service unavailable" }, { status: 503 });
}
if (!isPro) {
return Response.json({ error: "Creator digests are available for Ecency Pro creators" }, { status: 403 });
}
}
// Creator digests are open to EVERY creator (decided 2026-08-19): anyone may
// subscribe to any account's digest, and the automatic weekly/monthly digest
// sends for any list with readers. Pro keeps the active capabilities: sending
// a chosen post and composing issues (see newsletter-sender-gate).

const upstream = await callNewsletter("/api/subscriptions", {
method: "POST",
Expand Down
13 changes: 4 additions & 9 deletions apps/web/src/features/newsletter/digest-subscribe-button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"use client";

import { isProMember } from "@/features/pro";
import { getProMembersQueryOptions } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";
import { UilEnvelope, UilEnvelopeCheck } from "@tooni/iconscout-unicons-react";
import { Button, ButtonProps } from "@ui/button";
import i18next from "i18next";
Expand Down Expand Up @@ -60,13 +57,11 @@ export function DigestSubscribeButton({ type, target, targetLabel, source, size,
const enabled = useNewsletterEnabled();
const [open, setOpen] = useState(false);
const { subscription } = useDigestSubscription(type, target);
const proQuery = useQuery({ ...getProMembersQueryOptions(), enabled: enabled && type === "creator" });
const pro = proQuery.data;
const offered = enabled && (type !== "creator" || isProMember(pro?.members, target));
// For a creator list, "offered" is unknown until the roster has answered.
const eligibilityKnown = type !== "creator" || !enabled || proQuery.isSuccess || proQuery.isError;
// Creator digests are open to every creator (2026-08-19): no Pro gate here,
// eligibility is synchronous and the shared-link opener can act at once.
const offered = enabled;
const openFromLink = useCallback(() => setOpen(true), []);
useSubscribeLinkOpener(!eligibilityKnown ? undefined : offered ? openFromLink : null);
useSubscribeLinkOpener(offered ? openFromLink : null);

if (!offered) return null;

Expand Down
Loading
Loading