diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx
index c3cb87df6..b755a571f 100644
--- a/frontend/src/pages/clients/ClientInfoModal.tsx
+++ b/frontend/src/pages/clients/ClientInfoModal.tsx
@@ -357,7 +357,7 @@ export default function ClientInfoModal({
const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
- const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
+ const qrRemark = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
diff --git a/internal/sub/characterization_test.go b/internal/sub/characterization_test.go
index acccb567c..416baba31 100644
--- a/internal/sub/characterization_test.go
+++ b/internal/sub/characterization_test.go
@@ -45,8 +45,8 @@ func TestChar_C1_VlessExternalProxy(t *testing.T) {
}`
s := &SubService{}
got := s.genVlessLink(charVlessInbound(stream), "user")
- want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
- "vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2"
+ want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1-user\n" +
+ "vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2-user"
if got != want {
t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -106,8 +106,8 @@ func TestChar_C2_VmessExternalProxy(t *testing.T) {
}
s := &SubService{}
got := s.genVmessLink(in, "user")
- want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
- "vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
+ want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
+ "vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
if got != want {
t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -143,7 +143,7 @@ func TestChar_C3_TrojanExternalProxy(t *testing.T) {
}
s := &SubService{}
got := s.genTrojanLink(in, "user")
- want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
+ want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ-user"
if got != want {
t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
}
s := &SubService{}
got := s.genShadowsocksLink(in, "user")
- want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
+ want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS-user"
if got != want {
t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
}
diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go
index b420982ce..230ef29f5 100644
--- a/internal/sub/endpoint_test.go
+++ b/internal/sub/endpoint_test.go
@@ -83,8 +83,8 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
)
- want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
- "vless://uid@b.example.com:80?security=none&type=tcp#ib-B"
+ want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
+ "vless://uid@b.example.com:80?security=none&type=tcp#ib-B-user"
if got != want {
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -105,8 +105,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
}
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
- want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
- "vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
+ want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
+ "vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
if got != want {
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
}
diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go
index 29eac0f8a..e408926d3 100644
--- a/internal/sub/remark_vars.go
+++ b/internal/sub/remark_vars.go
@@ -524,13 +524,13 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
return ctx.configName()
}
-// genHostRemark builds one host endpoint's remark for a specific client. The
-// config name is always the inbound's own remark; the host's remark is surfaced
-// only through the {{HOST}} token. In the subscription body the rest of the
-// remark template still applies; displays show just the config name.
+// genHostRemark builds one host endpoint's remark for a specific client. In the
+// subscription body the {{HOST}} token carries the host's remark and the rest of
+// the template still applies; displays show the config name, host and email.
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if !s.subscriptionBody {
- return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
+ name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
+ return fallbackRemark(name, hostRemark, client.Email)
}
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
}
diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go
index 6c8c43a23..4a567238d 100644
--- a/internal/sub/remark_vars_test.go
+++ b/internal/sub/remark_vars_test.go
@@ -206,12 +206,11 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
}
}
-// With no template, genRemark composes the fallback model and adds no suffix.
-func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
+func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay", "")
- if got != "DE-Relay" {
- t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
+ if got != "DE-Relay-john@example.com" {
+ t.Fatalf("genRemark = %q, want %q", got, "DE-Relay-john@example.com")
}
}
@@ -232,24 +231,17 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
}
}
-// Outside the subscription body (panel link/QR displays, sub info page) the
-// template is bypassed entirely — links show just the config name, with no
-// per-client email or usage info.
func TestRemarkInDisplayContext(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s.subscriptionBody = false
- // A host link in a display shows only the config name — the inbound's remark,
- // with no per-client email or usage info and the host remark ignored.
- if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
- t.Fatalf("display host link = %q, want config name %q", got, "DE")
+ if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE-CDN-john@example.com" {
+ t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com")
}
- // With no host remark, the config name is likewise the inbound's own remark.
- if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
- t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
+ if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
+ t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com")
}
- // genRemark (non-host) likewise drops the template in display context.
- if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
- t.Fatalf("display genRemark = %q, want %q", got, "DE")
+ if got := s.genRemark(inbound, client.Email, "", ""); got != "DE-john@example.com" {
+ t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com")
}
}
diff --git a/internal/sub/service.go b/internal/sub/service.go
index 76d9c26dd..a9e57b4b8 100644
--- a/internal/sub/service.go
+++ b/internal/sub/service.go
@@ -1641,24 +1641,17 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if s.remarkTemplate != "" && s.subscriptionBody {
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
}
- // Sub info page + panel link/QR displays: just the config name (no template,
- // so no per-client email/usage leaks into the shown remark).
- return fallbackRemark(inbound.Remark, extra)
+ return fallbackRemark(inbound.Remark, extra, email)
}
-// fallbackRemark is the minimal remark used only when no template is configured
-// (an operator explicitly cleared it): the inbound remark and the host/extra
-// remark joined by "-", skipping empties. The configurable remark model was
-// removed in favour of the template, whose default already includes the email.
-func fallbackRemark(inboundRemark, extra string) string {
- switch {
- case inboundRemark == "":
- return extra
- case extra == "":
- return inboundRemark
- default:
- return inboundRemark + "-" + extra
+func fallbackRemark(parts ...string) string {
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p != "" {
+ out = append(out, p)
+ }
}
+ return strings.Join(out, "-")
}
// findClientStats returns the inbound's traffic record for email, if present.