Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc9d4b9c4 | ||
|
|
50ab63ff13 | ||
|
|
5c87505820 | ||
|
|
e1ad2467d2 | ||
|
|
059949674d |
@@ -7,7 +7,7 @@ The private Go module every Vantage service imports. Extracted from the
|
||||
vantage-shared/
|
||||
├── grpc/pb/ # the agent↔control-plane wire types, hand-written
|
||||
├── grpc/codec/ # the JSON codec they travel over
|
||||
├── proto/vantage/v1/ # vantage.proto — documentation for the above
|
||||
├── proto/vantage/v1/ # vantage.proto - documentation for the above
|
||||
├── mail/ # the one email system: transport + templates
|
||||
├── license/ # payload, sign, verify, trusted keys, plans
|
||||
├── models/ # Instance, User, Settings
|
||||
@@ -18,7 +18,7 @@ vantage-shared/
|
||||
└── cmd/lkctl/ # issue and inspect licences by hand
|
||||
```
|
||||
|
||||
Module path is `gitea.hostxtra.co.uk/vantage/vantage-shared` — **lowercase
|
||||
Module path is `gitea.hostxtra.co.uk/vantage/vantage-shared` - **lowercase
|
||||
`vantage`**, though the Gitea org is canonically `Vantage`. Gitea serves both
|
||||
spellings; Go module paths are case-sensitive strings, and two spellings would
|
||||
cache as two modules. Keep the lowercase one.
|
||||
@@ -46,7 +46,7 @@ v0.2.0`. Consumers pin exact versions.
|
||||
`grpc/codec`. Nothing generates them, and `vantage.proto` is not compiled by any
|
||||
build. It is the readable statement of the wire contract, and it lives in the
|
||||
same repository as the Go types precisely so that a message added to one can be
|
||||
added to the other **in the same commit** — that co-location is the only thing
|
||||
added to the other **in the same commit** - that co-location is the only thing
|
||||
enforcing the match, so do not split them again.
|
||||
|
||||
There used to be two copies of `pb`, in the agent and the server, and they had
|
||||
@@ -57,7 +57,7 @@ sides. The agent links the server half as dead code, which the linker drops.
|
||||
**A wire change lands in three steps, in this order**: release this module, bump
|
||||
the pin in `vantage/server` (live at the next push to main), bump the pin in
|
||||
`vantage/agent` (live only at the next `agent/v*` tag). The control plane will
|
||||
be ahead of the fleet in between, which was true before too — it is just written
|
||||
be ahead of the fleet in between, which was true before too - it is just written
|
||||
down in two `go.mod` files now instead of implied by a shared directory.
|
||||
|
||||
## Things that mirror something outside this repository
|
||||
@@ -71,7 +71,7 @@ with a file in another repository:
|
||||
probe finds no ciphertext and reports "this database stores no ciphertext
|
||||
yet", so the one gate that catches what a key fingerprint cannot becomes a
|
||||
no-op. `settings` is deliberately in neither that map nor
|
||||
`CiphertextCollections()` — its ESO read token is a SHA-256 hash, not
|
||||
`CiphertextCollections()` - its ESO read token is a SHA-256 hash, not
|
||||
ciphertext.
|
||||
- **`mail/templates/layout.html.tmpl`** carries the control plane's dark theme
|
||||
values as **literal hex**. Email clients support neither `var()` nor a
|
||||
@@ -81,8 +81,12 @@ with a file in another repository:
|
||||
- **`provision`** is the single implementation of the slug rules that both the
|
||||
control plane and Vantage HQ depend on. It is the one place those two
|
||||
repositories must agree on behaviour, which is why it is here rather than
|
||||
copied — but it now also means a change to it is a release and two pin bumps.
|
||||
copied - but it now also means a change to it is a release and two pin bumps.
|
||||
|
||||
`mail/render_test.go` renders every template and fails if one exists that no
|
||||
case covers. The templates are parsed in `init()`, so without that test a
|
||||
mistyped field is a boot-time panic in three services.
|
||||
|
||||
## Writing style
|
||||
|
||||
Never use em dashes (the long dash character) anywhere: code, comments, UI copy, docs, commit messages. Use a plain hyphen ` - `, a comma, a colon, or split the sentence instead.
|
||||
|
||||
+2
-2
@@ -98,8 +98,8 @@ func indexMember(name string) string { return "indexes/" + name + ".json" }
|
||||
// Reader is an opened archive.
|
||||
//
|
||||
// Open extracts to a temporary directory rather than streaming, because gzip
|
||||
// offers no random access and the manifest — which carries the checksums every
|
||||
// other member is judged against — is written last. Verifying before writing a
|
||||
// offers no random access and the manifest - which carries the checksums every
|
||||
// other member is judged against - is written last. Verifying before writing a
|
||||
// single document to the target is worth one pass over local disk. This is why
|
||||
// the container image needs a /tmp.
|
||||
type Reader struct {
|
||||
|
||||
+2
-2
@@ -148,8 +148,8 @@ func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string
|
||||
defer cur.Close(ctx)
|
||||
|
||||
// The specs are read as raw BSON and re-encoded as extended JSON, one
|
||||
// element per index, so key order and every option the server reported —
|
||||
// partialFilterExpression, collation, weights and the rest — survive
|
||||
// element per index, so key order and every option the server reported -
|
||||
// partialFilterExpression, collation, weights and the rest - survive
|
||||
// verbatim. Decoding into bson.M would lose compound key order, and
|
||||
// reconstructing an index from a hand-picked set of options would drop
|
||||
// whatever was not picked.
|
||||
|
||||
+4
-4
@@ -72,7 +72,7 @@ func (o RestoreOptions) warn(format string, args ...any) {
|
||||
// The order is fixed and every check that can refuse does so before the first
|
||||
// write: format, checksums (done by Open), key policy, then target inspection.
|
||||
// A restore that has begun writing and then fails leaves a partial database
|
||||
// which the next run refuses to touch, which is correct — the alternative is a
|
||||
// which the next run refuses to touch, which is correct - the alternative is a
|
||||
// silent merge, and merging two control planes reconciles nothing.
|
||||
func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) {
|
||||
m := opt.Archive.Manifest()
|
||||
@@ -302,14 +302,14 @@ func splitBSON(raw []byte) (bson.Raw, []byte, error) {
|
||||
// The specs are handed to the createIndexes command exactly as the source
|
||||
// server reported them, rather than reconstructed into a mongo.IndexModel from
|
||||
// a hand-picked set of options. Reconstruction dropped every option nobody had
|
||||
// thought to pick — partialFilterExpression above all, which this codebase
|
||||
// thought to pick - partialFilterExpression above all, which this codebase
|
||||
// relies on for partial unique indexes, and which replayed as a full unique
|
||||
// index fails on any real database. It also lost compound key order, which is
|
||||
// significant.
|
||||
//
|
||||
// A unique index that will not build means the restored data violates it, and
|
||||
// the unique indexes here — (instance_id, email), instance slug, settings
|
||||
// instance, the ESO token hash — are tenant-isolation properties rather than
|
||||
// the unique indexes here - (instance_id, email), instance slug, settings
|
||||
// instance, the ESO token hash - are tenant-isolation properties rather than
|
||||
// optimisations. That aborts. A non-unique index failing is a performance
|
||||
// problem and warns.
|
||||
func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) {
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestRestoreAbortsWhenAUniqueIndexCannotBuild(t *testing.T) {
|
||||
|
||||
// Built by hand rather than dumped: two documents that collide on email
|
||||
// alongside an index specification declaring email unique. No live database
|
||||
// would let those coexist, which is exactly the point — this is the shape
|
||||
// would let those coexist, which is exactly the point - this is the shape
|
||||
// of a corrupted or hand-edited archive, and restore must refuse rather
|
||||
// than load the rows and leave the index missing.
|
||||
a, err := bson.Marshal(bson.M{"email": "a@example.com"})
|
||||
@@ -371,8 +371,8 @@ func TestIdIndexIsSkipped(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRestoreReplaysPartialUniqueIndex is the regression guard for the defect
|
||||
// that made a restore abort on any real database: a partial unique index —
|
||||
// this codebase has them on workflow_steps and settings — replayed as a full
|
||||
// that made a restore abort on any real database: a partial unique index -
|
||||
// this codebase has them on workflow_steps and settings - replayed as a full
|
||||
// unique index hits duplicate keys, and a failing unique index is fatal.
|
||||
func TestRestoreReplaysPartialUniqueIndex(t *testing.T) {
|
||||
client, _ := testDB(t)
|
||||
|
||||
+7
-7
@@ -109,8 +109,8 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
|
||||
rep.ProbeDecrypted = true
|
||||
return nil
|
||||
}
|
||||
// No ciphertext anywhere is an ordinary state — a deployment that has
|
||||
// stored no secrets, keys or SSO configuration yet — and is not a failure.
|
||||
// No ciphertext anywhere is an ordinary state - a deployment that has
|
||||
// stored no secrets, keys or SSO configuration yet - and is not a failure.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,13 +121,13 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
|
||||
// This map MIRRORS BY HAND the bson tags in server/internal/models, which this
|
||||
// package cannot import: shared/ is a separate module and models is under
|
||||
// server/internal. It must change in the same commit as any rename of the
|
||||
// fields below — the same mirrored-constant hazard as web/lib/targets.ts and
|
||||
// fields below - the same mirrored-constant hazard as web/lib/targets.ts and
|
||||
// services.MaxWorkloadLogLines. The sources are:
|
||||
//
|
||||
// keys — models/key.go: private_key_enc, passphrase_enc
|
||||
// secrets — models/secret.go: encrypted_value
|
||||
// auth_providers — models/auth_provider.go: client_secret_enc
|
||||
// console_sessions — models/console_session.go: rdp_user_enc, rdp_pass_enc
|
||||
// keys - models/key.go: private_key_enc, passphrase_enc
|
||||
// secrets - models/secret.go: encrypted_value
|
||||
// auth_providers - models/auth_provider.go: client_secret_enc
|
||||
// console_sessions - models/console_session.go: rdp_user_enc, rdp_pass_enc
|
||||
//
|
||||
// settings is deliberately absent: it holds no ciphertext at all. The ESO read
|
||||
// token is stored as a SHA-256 hash, which no key opens.
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ const (
|
||||
FeatureMCP = "mcp"
|
||||
)
|
||||
|
||||
// Support levels. Carried for display and enforced by nothing — there is no code
|
||||
// Support levels. Carried for display and enforced by nothing - there is no code
|
||||
// path anywhere that branches on these, and there must not be one. They are here
|
||||
// so an air-gapped install can tell its operator who to call without reaching
|
||||
// Vantage HQ.
|
||||
@@ -74,7 +74,7 @@ type Limits struct {
|
||||
// FillUnset replaces any zero field with the same field from base.
|
||||
//
|
||||
// This exists for one reason: a licence signed before a field existed decodes it
|
||||
// as 0, and 0 would read as the most restrictive possible value — no monitors,
|
||||
// as 0, and 0 would read as the most restrictive possible value - no monitors,
|
||||
// and an audit log trimmed to nothing. A blob we cannot re-sign must not be
|
||||
// allowed to mean that.
|
||||
//
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ package license
|
||||
//
|
||||
// This table is the seed. The admin service owns the authoritative copy in its
|
||||
// `plans` collection, and every issued licence snapshots the plan it was cut
|
||||
// from — so editing a plan never rewrites an existing licence, the same rule as
|
||||
// from - so editing a plan never rewrites an existing licence, the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
//
|
||||
// Limits here are the BASE allowance: what the tier grants before anything is
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ func Verify(blob string, opts VerifyOpts) Result {
|
||||
// Parse verifies the signature only, ignoring binding and expiry.
|
||||
//
|
||||
// Used to display a licence and to inspect a blob a customer has emailed in.
|
||||
// Never use it for enforcement — it does not check who the licence is for.
|
||||
// Never use it for enforcement - it does not check who the licence is for.
|
||||
func Parse(blob string) (License, error) {
|
||||
parsed, err := lk.LicenseFromB32String(blob)
|
||||
if err != nil {
|
||||
|
||||
+22
-4
@@ -14,24 +14,42 @@ const VerifyWindow = 24 * time.Hour
|
||||
// The link is built from the Sender's PublicURL, because the address of the
|
||||
// portal is a property of the deployment, not of the call site.
|
||||
func (s Sender) SendVerification(to, token string) error {
|
||||
return s.sendTemplate(to, "", "verification", struct {
|
||||
return s.sendTemplate(to, "", "verification", s.verificationData(to, token))
|
||||
}
|
||||
|
||||
// verificationData is split from its Send method, as every message's is, so
|
||||
// render_test.go renders the exact struct the message is sent with.
|
||||
func (s Sender) verificationData(to, token string) any {
|
||||
return struct {
|
||||
Email string
|
||||
Link string
|
||||
TTLHours int
|
||||
Expires time.Time
|
||||
}{
|
||||
Email: to,
|
||||
Link: fmt.Sprintf("%s/verify?token=%s", s.PublicURL, token),
|
||||
TTLHours: int(VerifyWindow.Hours()),
|
||||
})
|
||||
Expires: time.Now().UTC().Add(VerifyWindow),
|
||||
}
|
||||
}
|
||||
|
||||
// SendInvite asks someone to join an existing account and set their own
|
||||
// password. It names the account, because an unexpected invitation from a
|
||||
// service you have never used is otherwise indistinguishable from spam.
|
||||
func (s Sender) SendInvite(to, accountName, token string) error {
|
||||
return s.sendTemplate(to, "", "invite", struct {
|
||||
return s.sendTemplate(to, "", "invite", s.inviteData(to, accountName, token))
|
||||
}
|
||||
|
||||
func (s Sender) inviteData(to, accountName, token string) any {
|
||||
return struct {
|
||||
Email string
|
||||
AccountName string
|
||||
Link string
|
||||
TTLHours int
|
||||
}{
|
||||
Email: to,
|
||||
AccountName: accountName,
|
||||
Link: fmt.Sprintf("%s/accept-invite?token=%s", s.PublicURL, token),
|
||||
})
|
||||
TTLHours: int(VerifyWindow.Hours()),
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -3,16 +3,18 @@ package mail
|
||||
// SendCancelled confirms a cancellation and states what stays true: the licence
|
||||
// keeps working until it expires, then the instance degrades to read-only.
|
||||
func (s Sender) SendCancelled(to, instanceName string) error {
|
||||
return s.sendTemplate(to, "", "cancelled", struct {
|
||||
InstanceName string
|
||||
}{instanceName})
|
||||
return s.sendTemplate(to, "", "cancelled", s.billingData(instanceName))
|
||||
}
|
||||
|
||||
// SendPastDue notifies of a failed charge without alarming: the licence is
|
||||
// untouched while Paddle retries the card.
|
||||
func (s Sender) SendPastDue(to, instanceName string) error {
|
||||
return s.sendTemplate(to, "", "pastdue", struct {
|
||||
return s.sendTemplate(to, "", "pastdue", s.billingData(instanceName))
|
||||
}
|
||||
|
||||
func (s Sender) billingData(instanceName string) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
}{instanceName, s.PublicURL})
|
||||
}{instanceName, s.PublicURL}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package mail
|
||||
|
||||
import "time"
|
||||
|
||||
// DisputeNotice is everything the four account-dispute emails say.
|
||||
//
|
||||
// vantage-admin's dispute package builds it. It never carries the staff note:
|
||||
// the customer is told the reason category, not what staff wrote about them.
|
||||
type DisputeNotice struct {
|
||||
AccountName string
|
||||
DisputeID string
|
||||
Reason string
|
||||
CloudNames []string
|
||||
SelfHosted []SelfHostedNotice
|
||||
DisputeEmail string
|
||||
DisputeBy time.Time
|
||||
}
|
||||
|
||||
// SelfHostedNotice names a self-hosted install and when its licence ends,
|
||||
// because that is the one thing a dispute cannot change for it.
|
||||
type SelfHostedNotice struct {
|
||||
Name string
|
||||
LicenceExpires time.Time
|
||||
}
|
||||
|
||||
// InstanceRows lists the affected instances in the shape the layout's "rows"
|
||||
// helper takes. A method rather than template logic, because a Go template
|
||||
// has no way to build a list of maps.
|
||||
func (n DisputeNotice) InstanceRows() []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(n.CloudNames)+len(n.SelfHosted))
|
||||
for _, name := range n.CloudNames {
|
||||
rows = append(rows, map[string]any{"k": "Cloud", "v": name})
|
||||
}
|
||||
for _, s := range n.SelfHosted {
|
||||
rows = append(rows, map[string]any{
|
||||
"k": "Self-hosted",
|
||||
"v": s.Name + ", licence ends " + s.LicenceExpires.Format("2 January 2006"),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// SendAccountLocked tells the account a dispute has locked it, that nothing is
|
||||
// deleted, and how to dispute. Replies go to the dispute address.
|
||||
func (s Sender) SendAccountLocked(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, n.DisputeEmail, "accountlocked", n)
|
||||
}
|
||||
|
||||
// SendDisputeReminder repeats the dispute route shortly before the hold ends.
|
||||
func (s Sender) SendDisputeReminder(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, n.DisputeEmail, "disputereminder", n)
|
||||
}
|
||||
|
||||
// SendAccountTerminated states the outcome of a failed dispute.
|
||||
func (s Sender) SendAccountTerminated(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, n.DisputeEmail, "accountterminated", n)
|
||||
}
|
||||
|
||||
// SendAccountRestored states the outcome of an upheld dispute.
|
||||
func (s Sender) SendAccountRestored(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, "", "accountrestored", n)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sampleNotice() DisputeNotice {
|
||||
return DisputeNotice{
|
||||
AccountName: "Smith & Sons",
|
||||
DisputeID: "dsp_123",
|
||||
Reason: "Breach of the terms of service",
|
||||
CloudNames: []string{"smith-prod"},
|
||||
SelfHosted: []SelfHostedNotice{{Name: "smith-dc", LicenceExpires: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)}},
|
||||
DisputeEmail: "disputes@vantage.example",
|
||||
DisputeBy: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
var disputeTemplates = []string{"accountlocked", "disputereminder", "accountterminated", "accountrestored"}
|
||||
|
||||
func TestDisputeTemplatesRender(t *testing.T) {
|
||||
for _, name := range disputeTemplates {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, err := render(name, sampleNotice())
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.Subject, "Smith & Sons") {
|
||||
t.Errorf("subject %q does not name the account verbatim", m.Subject)
|
||||
}
|
||||
for label, body := range map[string]string{"text": m.Text, "html": m.HTML} {
|
||||
if strings.Contains(body, "<no value>") {
|
||||
t.Errorf("%s body has an unfilled field", label)
|
||||
}
|
||||
if strings.Contains(body, "—") {
|
||||
t.Errorf("%s body contains an em dash", label)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisputeRouteIsStated(t *testing.T) {
|
||||
for _, name := range []string{"accountlocked", "disputereminder"} {
|
||||
m, err := render(name, sampleNotice())
|
||||
if err != nil {
|
||||
t.Fatalf("render %s: %v", name, err)
|
||||
}
|
||||
for _, want := range []string{"disputes@vantage.example", "dsp_123", "17 September 2026"} {
|
||||
if !strings.Contains(m.Text, want) {
|
||||
t.Errorf("%s text is missing %q", name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceRows(t *testing.T) {
|
||||
rows := sampleNotice().InstanceRows()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(rows))
|
||||
}
|
||||
if rows[0]["v"] != "smith-prod" {
|
||||
t.Errorf("cloud row = %v", rows[0])
|
||||
}
|
||||
if v, _ := rows[1]["v"].(string); !strings.Contains(v, "licence ends 2 November 2026") {
|
||||
t.Errorf("self-hosted row = %v", rows[1])
|
||||
}
|
||||
}
|
||||
+39
-13
@@ -5,13 +5,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendLicense delivers the blob inline. It is signed public data, not a secret —
|
||||
// SendLicense delivers the blob inline. It is signed public data, not a secret -
|
||||
// it is useless on any instance other than the one it names.
|
||||
func (s Sender) SendLicense(to, instanceName, blob string) error {
|
||||
return s.sendTemplate(to, "", "license", struct {
|
||||
return s.sendTemplate(to, "", "license", licenseData(instanceName, blob))
|
||||
}
|
||||
|
||||
func licenseData(instanceName, blob string) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
Blob string
|
||||
}{instanceName, blob})
|
||||
Issued time.Time
|
||||
}{instanceName, blob, time.Now().UTC()}
|
||||
}
|
||||
|
||||
// SendInstanceReady tells a customer their cloud instance exists, where it is,
|
||||
@@ -21,28 +26,40 @@ func (s Sender) SendLicense(to, instanceName, blob string) error {
|
||||
// that quietly expires in a month is a surprise, and the first email is the one
|
||||
// people keep.
|
||||
func (s Sender) SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
|
||||
return s.sendTemplate(to, "", "instanceready", struct {
|
||||
return s.sendTemplate(to, "", "instanceready", instanceReadyData(instanceName, loginURL, expires))
|
||||
}
|
||||
|
||||
func instanceReadyData(instanceName, loginURL string, expires time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
LoginURL string
|
||||
Expires time.Time
|
||||
}{instanceName, loginURL, expires})
|
||||
}{instanceName, loginURL, expires}
|
||||
}
|
||||
|
||||
// SendRenewed confirms a renewal and states the new date.
|
||||
func (s Sender) SendRenewed(to, instanceName string, expires time.Time) error {
|
||||
return s.sendTemplate(to, "", "renewed", struct {
|
||||
return s.sendTemplate(to, "", "renewed", renewedData(instanceName, expires))
|
||||
}
|
||||
|
||||
func renewedData(instanceName string, expires time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
Expires time.Time
|
||||
}{instanceName, expires})
|
||||
}{instanceName, expires}
|
||||
}
|
||||
|
||||
// SendExpiring is the renew-now nudge, seven days out.
|
||||
func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
|
||||
return s.sendTemplate(to, "", "expiring", struct {
|
||||
return s.sendTemplate(to, "", "expiring", expiringData(instanceName, portalURL, expires))
|
||||
}
|
||||
|
||||
func expiringData(instanceName, portalURL string, expires time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
Expires time.Time
|
||||
}{instanceName, portalURL, expires})
|
||||
}{instanceName, portalURL, expires}
|
||||
}
|
||||
|
||||
// SendExpired states plainly what has stopped and what happens next.
|
||||
@@ -51,23 +68,32 @@ func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Ti
|
||||
// sequence is that nobody loses an instance without having been told a date. A
|
||||
// zero deleteOn means the reaper is disabled, and then no date is claimed.
|
||||
func (s Sender) SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
|
||||
return s.sendTemplate(to, "", "expired", struct {
|
||||
return s.sendTemplate(to, "", "expired", expiredData(instanceName, portalURL, deleteOn))
|
||||
}
|
||||
|
||||
func expiredData(instanceName, portalURL string, deleteOn time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
DeleteOn time.Time
|
||||
}{instanceName, portalURL, deleteOn})
|
||||
}{instanceName, portalURL, deleteOn}
|
||||
}
|
||||
|
||||
// SendDeletionWarning is the final countdown, sent at seven days and one day.
|
||||
func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
|
||||
return s.sendTemplate(to, "", "deletionwarning", deletionWarningData(instanceName, portalURL, deleteOn, daysLeft))
|
||||
}
|
||||
|
||||
func deletionWarningData(instanceName, portalURL string, deleteOn time.Time, daysLeft int) any {
|
||||
when := fmt.Sprintf("in %d days", daysLeft)
|
||||
if daysLeft <= 1 {
|
||||
when = "tomorrow"
|
||||
}
|
||||
return s.sendTemplate(to, "", "deletionwarning", struct {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
When string
|
||||
DaysLeft int
|
||||
DeleteOn time.Time
|
||||
}{instanceName, portalURL, when, deleteOn})
|
||||
}{instanceName, portalURL, when, daysLeft, deleteOn}
|
||||
}
|
||||
|
||||
+100
-6
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
htmltmpl "html/template"
|
||||
"io/fs"
|
||||
"math"
|
||||
"strings"
|
||||
texttmpl "text/template"
|
||||
"time"
|
||||
@@ -36,11 +37,13 @@ func init() {
|
||||
continue
|
||||
}
|
||||
h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)).
|
||||
Funcs(htmltmpl.FuncMap(perRender("", "accent"))).
|
||||
ParseFS(files, "templates/layout.html.tmpl", e)
|
||||
if err != nil {
|
||||
panic("mail: parse " + e + ": " + err.Error())
|
||||
}
|
||||
t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)).
|
||||
Funcs(texttmpl.FuncMap(perRender("", "accent"))).
|
||||
ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl")
|
||||
if err != nil {
|
||||
panic("mail: parse " + name + ".txt.tmpl: " + err.Error())
|
||||
@@ -49,25 +52,58 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// render produces the subject and both bodies for one message.
|
||||
// perRender are the funcs whose value belongs to one message rather than to
|
||||
// the package: the sender's portal address for the footer links, and the
|
||||
// message's tone, which the layout needs before the body has run. They are
|
||||
// registered with placeholders at parse time and rebound on a clone per render.
|
||||
func perRender(hq, tone string) map[string]any {
|
||||
return map[string]any{
|
||||
"hq": func() string { return hq },
|
||||
"tone": func() string { return tone },
|
||||
}
|
||||
}
|
||||
|
||||
// render produces the subject and both bodies for one message. hq is the
|
||||
// sender's PublicURL; empty drops the footer links.
|
||||
//
|
||||
// The subject comes from the text set, not the HTML one: html/template would
|
||||
// escape an ampersand in an instance name into "&" and mail clients show
|
||||
// subjects verbatim.
|
||||
func render(name string, data any) (message, error) {
|
||||
func render(name string, data any, hq ...string) (message, error) {
|
||||
s, ok := sets[name]
|
||||
if !ok {
|
||||
return message{}, fmt.Errorf("no such template")
|
||||
}
|
||||
var hqURL string
|
||||
if len(hq) > 0 {
|
||||
hqURL = hq[0]
|
||||
}
|
||||
|
||||
var tone strings.Builder
|
||||
if err := s.text.ExecuteTemplate(&tone, "tone", data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
per := perRender(hqURL, strings.TrimSpace(tone.String()))
|
||||
|
||||
ht, err := s.html.Clone()
|
||||
if err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
ht.Funcs(htmltmpl.FuncMap(per))
|
||||
tt, err := s.text.Clone()
|
||||
if err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
tt.Funcs(texttmpl.FuncMap(per))
|
||||
|
||||
var subject, text, html strings.Builder
|
||||
if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil {
|
||||
if err := tt.ExecuteTemplate(&subject, "subject", data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if err := s.text.Execute(&text, data); err != nil {
|
||||
if err := tt.Execute(&text, data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if err := s.html.Execute(&html, data); err != nil {
|
||||
if err := ht.Execute(&html, data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
|
||||
@@ -90,7 +126,7 @@ func normaliseText(s string) string {
|
||||
}
|
||||
|
||||
// funcs are shared by both template flavours. They exist so that a message
|
||||
// template never formats a date or builds a structure itself — two templates
|
||||
// template never formats a date or builds a structure itself - two templates
|
||||
// formatting the same date two ways is exactly the drift this package removes.
|
||||
var funcs = map[string]any{
|
||||
// dict builds a map for the layout's helper templates, which take more
|
||||
@@ -110,6 +146,24 @@ var funcs = map[string]any{
|
||||
return m, nil
|
||||
},
|
||||
"list": func(v ...any) []any { return v },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
|
||||
// pick chooses one of five values by tone: up, down, pend, accent, and
|
||||
// anything else. It lets the layout keep every hex while the tone of a
|
||||
// message is decided by the message.
|
||||
"pick": func(tone any, up, down, pend, accent, other string) string {
|
||||
switch fmt.Sprint(tone) {
|
||||
case "up":
|
||||
return up
|
||||
case "down":
|
||||
return down
|
||||
case "pend":
|
||||
return pend
|
||||
case "accent":
|
||||
return accent
|
||||
}
|
||||
return other
|
||||
},
|
||||
|
||||
// date is the one long-date format used across every Vantage email.
|
||||
"date": func(t time.Time) string { return t.Format("2 January 2006") },
|
||||
@@ -118,4 +172,44 @@ var funcs = map[string]any{
|
||||
"stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") },
|
||||
"hours": func(d time.Duration) int { return int(d.Hours()) },
|
||||
"upper": strings.ToUpper,
|
||||
"year": func() int { return time.Now().Year() },
|
||||
|
||||
// daysUntil counts whole days left, rounding up so "expires tomorrow
|
||||
// afternoon" reads as 1, never 0. A date in the past is 0.
|
||||
"daysUntil": func(t time.Time) int {
|
||||
d := time.Until(t)
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Ceil(d.Hours() / 24))
|
||||
},
|
||||
"plural": func(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, one)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
},
|
||||
|
||||
// sevTone maps a scanner severity onto a layout tone, so the vulnerability
|
||||
// digest colours a finding the same way the control plane does.
|
||||
"sevTone": func(sev string) string {
|
||||
switch strings.ToLower(sev) {
|
||||
case "critical", "high":
|
||||
return "down"
|
||||
case "medium":
|
||||
return "pend"
|
||||
}
|
||||
return "accent"
|
||||
},
|
||||
// advisoryURL links a finding to its public advisory when the ID says
|
||||
// where that is. Other IDs get no link rather than a guessed one.
|
||||
"advisoryURL": func(id string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "CVE-"):
|
||||
return "https://nvd.nist.gov/vuln/detail/" + id
|
||||
case strings.HasPrefix(id, "GHSA-"):
|
||||
return "https://github.com/advisories/" + id
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cases renders every message with realistic data. Each entry's key is the
|
||||
// template name, optionally with a "/variant" suffix for a message whose
|
||||
// branches are worth seeing separately.
|
||||
func cases() map[string]any {
|
||||
s := Sender{PublicURL: "https://vantage-hq.hostxtra.co.uk"}
|
||||
now := time.Now().UTC()
|
||||
day := 24 * time.Hour
|
||||
return map[string]any{
|
||||
"verification": s.verificationData("sam@example.com", "3f9c2a7d1e"),
|
||||
"invite": s.inviteData("sam@example.com", "Northwind Ops", "8a1b2c3d4e"),
|
||||
"license": licenseData("northwind-prod",
|
||||
"eyJ2IjoxLCJpbnN0YW5jZSI6Im5vcnRod2luZC1wcm9kIiwidGllciI6ImZyZWUiLCJleHAiOjE3OTk5OTk5OTl9.MEUCIQDk3v1rX0sX7kQ2m9WQ1o0q8pWm6lq3vRjz8yN0yX4bGQIgYk2Fh7s"),
|
||||
"instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)),
|
||||
"instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)),
|
||||
"renewed": renewedData("northwind-prod", now.Add(30*day)),
|
||||
"expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)),
|
||||
"expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)),
|
||||
"expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}),
|
||||
"deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7),
|
||||
"deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1),
|
||||
"cancelled": s.billingData("northwind-prod"),
|
||||
"pastdue": s.billingData("northwind-prod"),
|
||||
"monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true},
|
||||
"monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now},
|
||||
"contact": struct {
|
||||
Enquiry
|
||||
Received string
|
||||
}{Enquiry{Name: "Priya Shah", Email: "priya@acme.io", Servers: "50-100", Topic: "Sales",
|
||||
Message: "Hi,\n\nWe run about 80 Linux boxes across two sites and are looking at Vantage for patching and uptime.\nCould we get a demo next week?"}, now.Format(time.RFC1123)},
|
||||
"vuln_digest": VulnDigest{InstanceName: "northwind-prod", Count: 14, TopSeverity: "critical",
|
||||
Summary: "14 new findings across 3 servers, 2 of them critical.",
|
||||
Rows: []VulnDigestRow{
|
||||
{CVEID: "CVE-2026-31337", Severity: "critical", PackageName: "openssl", ServerName: "web-01", FixedIn: "3.0.15-1"},
|
||||
{CVEID: "CVE-2026-29001", Severity: "critical", PackageName: "sudo", ServerName: "db-02", FixedIn: "1.9.16p2"},
|
||||
{CVEID: "GHSA-4xq2-9r7m-p3wv", Severity: "high", PackageName: "golang.org/x/net", ServerName: "web-01"},
|
||||
{CVEID: "CVE-2026-20488", Severity: "medium", PackageName: "curl", ServerName: "worker-03", FixedIn: "8.9.1"},
|
||||
{CVEID: "CVE-2025-48112", Severity: "low", PackageName: "less", ServerName: "worker-03"},
|
||||
},
|
||||
More: 9, DBAge: "2 days"},
|
||||
"accountlocked": sampleNotice(),
|
||||
"disputereminder": sampleNotice(),
|
||||
"accountterminated": sampleNotice(),
|
||||
"accountrestored": sampleNotice(),
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAll renders every template and fails if one exists that no case
|
||||
// covers. The templates are parsed in init(), so without this test a mistyped
|
||||
// field is a boot-time panic in three services.
|
||||
//
|
||||
// Set MAIL_PREVIEW_DIR to also write each rendering to disk for a look.
|
||||
func TestRenderAll(t *testing.T) {
|
||||
covered := map[string]bool{}
|
||||
dir := os.Getenv("MAIL_PREVIEW_DIR")
|
||||
|
||||
for key, data := range cases() {
|
||||
name, _, _ := strings.Cut(key, "/")
|
||||
covered[name] = true
|
||||
|
||||
m, err := render(name, data, "https://vantage-hq.hostxtra.co.uk")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", key, err)
|
||||
}
|
||||
if m.Subject == "" {
|
||||
t.Errorf("%s: empty subject", key)
|
||||
}
|
||||
for part, body := range map[string]string{"subject": m.Subject, "text": m.Text, "html": m.HTML} {
|
||||
if strings.Contains(body, "<no value>") {
|
||||
t.Errorf("%s: %s part has <no value>", key, part)
|
||||
}
|
||||
if strings.Contains(body, "—") {
|
||||
t.Errorf("%s: %s part has an em dash", key, part)
|
||||
}
|
||||
}
|
||||
|
||||
if dir != "" {
|
||||
base := filepath.Join(dir, strings.ReplaceAll(key, "/", "--"))
|
||||
_ = os.WriteFile(base+".html", []byte(m.HTML), 0o644)
|
||||
_ = os.WriteFile(base+".txt", []byte(m.Text), 0o644)
|
||||
_ = os.WriteFile(base+".subject", []byte(m.Subject), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
for name := range sets {
|
||||
if !covered[name] {
|
||||
t.Errorf("template %q has no render case", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderNoPortal checks the footer degrades when the sender has no
|
||||
// PublicURL, as a monitor notification channel does not.
|
||||
func TestRenderNoPortal(t *testing.T) {
|
||||
m, err := render("monitoralert", MonitorEvent{MonitorName: "m", Type: "tcp", Time: time.Now(), Down: true}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(m.HTML, "/billing") {
|
||||
t.Error("footer links rendered without a portal URL")
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
// It owns three things that used to exist in three copies: the SMTP
|
||||
// conversation (including the 465-implicit-TLS case that net/smtp gets wrong),
|
||||
// the RFC 5322 envelope, and the rendered look of a Vantage email. Callers see
|
||||
// only typed Send* methods — nobody outside this package builds a subject line,
|
||||
// only typed Send* methods - nobody outside this package builds a subject line,
|
||||
// a MIME part or a colour.
|
||||
package mail
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
// timeout bounds the whole SMTP conversation. Without it a mail server that
|
||||
// accepts the connection and then stalls holds an HTTP request open until the
|
||||
// client gives up — and admin's signup rollback runs on that request's context.
|
||||
// client gives up - and admin's signup rollback runs on that request's context.
|
||||
const timeout = 15 * time.Second
|
||||
|
||||
// Sender is a configured SMTP destination. It is a value, not a singleton:
|
||||
@@ -78,7 +78,7 @@ type message struct {
|
||||
|
||||
// sendTemplate renders name against data and delivers the result.
|
||||
func (s Sender) sendTemplate(to, replyTo, name string, data any) error {
|
||||
m, err := render(name, data)
|
||||
m, err := render(name, data, s.PublicURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: render %s: %w", name, err)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func (s Sender) sendTemplate(to, replyTo, name string, data any) error {
|
||||
// Port 465 is implicit TLS: the server expects a TLS handshake immediately, so
|
||||
// the connection is wrapped BEFORE any SMTP is spoken. Every other port gets
|
||||
// plaintext then STARTTLS if offered. net/smtp.SendMail only does the latter,
|
||||
// which is why it fails against a 465 mail server — that bug silently stopped
|
||||
// which is why it fails against a 465 mail server - that bug silently stopped
|
||||
// every admin email from being delivered once already.
|
||||
func (s Sender) send(m message) error {
|
||||
if !s.Enabled() {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "subject"}}Your Vantage account {{.AccountName}} is locked{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}}
|
||||
{{define "title"}}Your account is restored{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}}
|
||||
{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed on the period you already paid for, so there is no charge today."}}
|
||||
{{template "p" "You will need to sign in again."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "subject"}}Your Vantage account {{.AccountName}} is restored{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}}
|
||||
{{define "title"}}Your account is restored{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}}
|
||||
{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed on the period you already paid for, so there is no charge today."}}
|
||||
{{template "p" "You will need to sign in again."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}}
|
||||
{{define "title"}}Your account is terminated{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}}
|
||||
{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "subject"}}Your Vantage account {{.AccountName}} is terminated{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}}
|
||||
{{define "title"}}Your account is terminated{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}}
|
||||
{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}}
|
||||
{{end}}
|
||||
@@ -1,6 +1,19 @@
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "preheader"}}No further charges. {{.InstanceName}} keeps working until its licence expires.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your subscription is cancelled{{end}}
|
||||
{{define "title"}}Your subscription for {{.InstanceName}} is cancelled{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled. No further payments will be taken." .InstanceName)}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Cancelled" "date" "Today" "state" "done")
|
||||
(dict "label" "Still active" "date" "Until licence expiry" "state" "now" "tone" "up")
|
||||
(dict "label" "Read-only" "date" "After that" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Subscription" "v" "Cancelled")
|
||||
(dict "k" "Further charges" "v" "None"))}}
|
||||
{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}}
|
||||
{{if .PortalURL}}{{template "callout" (dict "tone" "accent" "title" "Changed your mind?" "text" "You can start a new subscription from Vantage HQ at any time.")}}
|
||||
{{template "button" (dict "label" "Open Vantage HQ" "url" .PortalURL)}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the subscription for {{.InstanceName}} was cancelled.{{end}}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
{{define "subject"}}Your Vantage subscription is cancelled{{end}}
|
||||
{{define "subject"}}Your Vantage subscription for {{.InstanceName}} is cancelled{{end}}
|
||||
{{define "tone"}}pend{{end}}
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your subscription is cancelled{{end}}
|
||||
{{define "title"}}Your subscription for {{.InstanceName}} is cancelled{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled. No further payments will be taken." .InstanceName)}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Cancelled" "date" "Today" "state" "done")
|
||||
(dict "label" "Still active" "date" "Until licence expiry" "state" "now" "tone" "up")
|
||||
(dict "label" "Read-only" "date" "After that" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Subscription" "v" "Cancelled")
|
||||
(dict "k" "Further charges" "v" "None"))}}
|
||||
{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}}
|
||||
{{if .PortalURL}}{{template "callout" (dict "tone" "accent" "title" "Changed your mind?" "text" "You can start a new subscription from Vantage HQ at any time.")}}
|
||||
{{template "button" (dict "label" "Open Vantage HQ" "url" .PortalURL)}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the subscription for {{.InstanceName}} was cancelled.{{end}}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{{define "title"}}New contact enquiry{{end}}
|
||||
{{define "category"}}Enquiry{{end}}
|
||||
{{define "preheader"}}{{.Topic}} from {{.Name}}: {{.Message}}{{end}}
|
||||
{{define "pill"}}{{if .Topic}}{{template "chip" (dict "label" .Topic "tone" "accent")}}{{end}}{{end}}
|
||||
{{define "title"}}New enquiry from {{.Name}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Someone has used the contact form on the Vantage site."}}
|
||||
{{template "rows" (list
|
||||
@@ -7,5 +10,9 @@
|
||||
(dict "k" "Servers" "v" .Servers)
|
||||
(dict "k" "Topic" "v" .Topic)
|
||||
(dict "k" "Received" "v" .Received))}}
|
||||
{{template "label" "Message"}}
|
||||
{{template "note" .Message}}
|
||||
{{template "button" (dict "label" (printf "Reply to %s" .Name) "url" (printf "mailto:%s" .Email) "nofallback" true)}}
|
||||
{{template "small" (printf "Replying to this email also goes straight to %s." .Email)}}
|
||||
{{end}}
|
||||
{{define "why"}}this inbox receives the contact form on the Vantage site.{{end}}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{{define "subject"}}[Vantage] {{.Topic}} {{.Email}}{{end}}
|
||||
{{define "title"}}New contact enquiry{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Enquiry{{end}}
|
||||
{{define "pill"}}{{if .Topic}}{{template "chip" (dict "label" .Topic "tone" "accent")}}{{end}}{{end}}
|
||||
{{define "title"}}New enquiry from {{.Name}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Someone has used the contact form on the Vantage site."}}
|
||||
{{template "rows" (list
|
||||
@@ -8,6 +11,8 @@
|
||||
(dict "k" "Servers" "v" .Servers)
|
||||
(dict "k" "Topic" "v" .Topic)
|
||||
(dict "k" "Received" "v" .Received))}}
|
||||
Message:
|
||||
{{template "label" "Message"}}
|
||||
{{template "note" .Message}}
|
||||
{{template "small" (printf "Reply to this email to answer %s directly." .Email)}}
|
||||
{{end}}
|
||||
{{define "why"}}this inbox receives the contact form on the Vantage site.{{end}}
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Renew before {{date .DeleteOn}} to keep it. Deletion cannot be undone.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}}
|
||||
{{template "p" "This cannot be undone. Renew it to keep it:"}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural .DaysLeft "day" "days") "label" "until deletion" "tone" "down")
|
||||
(dict "big" (shortDate .DeleteOn) "label" "deletion date" "tone" "none"))}}
|
||||
{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}}
|
||||
{{template "callout" (dict "tone" "down" "title" "This cannot be undone" "text" (printf "Servers, agents, monitors, workflows, history and settings in %s are removed permanently. They cannot be restored afterwards." .InstanceName))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Licence" "date" "Expired" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "pend")
|
||||
(dict "label" "Deleted" "date" (date .DeleteOn) "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Current state" "v" "Read-only, licence expired")
|
||||
(dict "k" "Deleted on" "v" (date .DeleteOn)))}}
|
||||
{{template "small" "Renewed in the last few minutes? No action is needed. Renewing moves the deletion date with the licence."}}
|
||||
{{end}}
|
||||
{{define "why"}}{{.InstanceName}} is scheduled for deletion and this is its billing contact.{{end}}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
{{define "subject"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
|
||||
{{define "tone"}}down{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}}
|
||||
{{template "p" "This cannot be undone. Renew it to keep it:"}}
|
||||
{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}}
|
||||
{{template "callout" (dict "tone" "down" "title" "This cannot be undone" "text" (printf "Servers, agents, monitors, workflows, history and settings in %s are removed permanently. They cannot be restored afterwards." .InstanceName))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Licence" "date" "Expired" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "pend")
|
||||
(dict "label" "Deleted" "date" (date .DeleteOn) "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Current state" "v" "Read-only, licence expired")
|
||||
(dict "k" "Deleted on" "v" (date .DeleteOn))
|
||||
(dict "k" "Time left" "v" (plural .DaysLeft "day" "days")))}}
|
||||
{{template "small" "Renewed in the last few minutes? No action is needed. Renewing moves the deletion date with the licence."}}
|
||||
{{end}}
|
||||
{{define "why"}}{{.InstanceName}} is scheduled for deletion and this is its billing contact.{{end}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is still locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "subject"}}{{.AccountName}}: dispute deadline {{shortDate .DisputeBy}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is still locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -1,8 +1,23 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}The licence has expired. Renew to unlock changes{{if not .DeleteOn.IsZero}} before {{date .DeleteOn}}{{end}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is now read-only{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}}
|
||||
{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}}
|
||||
{{template "lead" (printf "%s's Free licence has expired, so the instance is now read-only." .InstanceName)}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .DeleteOn) "day" "days") "label" "until deletion" "tone" "down")
|
||||
(dict "big" (shortDate .DeleteOn) "label" "scheduled deletion" "tone" "none"))}}{{end}}
|
||||
{{template "button" (dict "label" "Renew now" "url" .PortalURL)}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}}
|
||||
{{template "label" "What still works"}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Servers and agents" "v" "Running, keys kept")
|
||||
(dict "k" "Monitors" "v" "Still checking")
|
||||
(dict "k" "Changes" "v" "Disabled until renewed")
|
||||
(dict "k" "Deletion" "v" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled")))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" "Ended" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "down")
|
||||
(dict "label" "Deleted" "date" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled") "state" "next"))}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "callout" (dict "tone" "down" "title" "Deletion is scheduled" "text" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn)))}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} has expired.{{end}}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
{{define "subject"}}{{.InstanceName}} is now read-only{{end}}
|
||||
{{define "tone"}}down{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is now read-only{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}}
|
||||
{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}}
|
||||
{{template "lead" (printf "%s's Free licence has expired, so the instance is now read-only." .InstanceName)}}
|
||||
{{template "button" (dict "label" "Renew now" "url" .PortalURL)}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}}
|
||||
{{template "label" "What still works"}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Servers and agents" "v" "Running, keys kept")
|
||||
(dict "k" "Monitors" "v" "Still checking")
|
||||
(dict "k" "Changes" "v" "Disabled until renewed")
|
||||
(dict "k" "Deletion" "v" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled")))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" "Ended" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "down")
|
||||
(dict "label" "Deleted" "date" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled") "state" "next"))}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "callout" (dict "tone" "down" "title" "Deletion is scheduled" "text" (printf "If it is not renewed, the instance and everything in it will be deleted on %s, in %s." (date .DeleteOn) (plural (daysUntil .DeleteOn) "day" "days")))}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} has expired.{{end}}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Renew in one click to keep {{.InstanceName}} fully working after {{date .Expires}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s. Renewing takes one click and costs nothing." .InstanceName (date .Expires))}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" "left on the licence" "tone" "pend")
|
||||
(dict "big" (shortDate .Expires) "label" "expiry date" "tone" "none"))}}
|
||||
{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}}
|
||||
{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}}
|
||||
{{template "label" "Where you are"}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "pend")
|
||||
(dict "label" "Read-only" "date" "After expiry" "state" "next")
|
||||
(dict "label" "Deleted" "date" "If never renewed" "state" "next"))}}
|
||||
{{template "callout" (dict "tone" "pend" "title" "If you do nothing" "text" "The instance keeps running and monitoring, but stops accepting changes until it is renewed.")}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} expires within a week.{{end}}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
{{define "subject"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
|
||||
{{define "tone"}}pend{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s. Renewing takes one click and costs nothing." .InstanceName (date .Expires))}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" "Left on the licence" "tone" "pend")
|
||||
(dict "big" (date .Expires) "label" "Expiry date" "tone" "none"))}}
|
||||
{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}}
|
||||
{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}}
|
||||
{{template "label" "Where you are"}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "pend")
|
||||
(dict "label" "Read-only" "date" "After expiry" "state" "next")
|
||||
(dict "label" "Deleted" "date" "If never renewed" "state" "next"))}}
|
||||
{{template "callout" (dict "tone" "pend" "title" "If you do nothing" "text" "The instance keeps running and monitoring, but stops accepting changes until it is renewed.")}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} expires within a week.{{end}}
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
{{define "category"}}Instance{{end}}
|
||||
{{define "preheader"}}{{.InstanceName}} is live. Your Free licence runs until {{date .Expires}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is ready{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}}
|
||||
{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}}
|
||||
{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" (printf "Sign in to %s" .InstanceName) "url" .LoginURL)}}{{end}}
|
||||
{{template "stats" (list
|
||||
(dict "big" "Free" "label" "plan" "tone" "accent")
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" (printf "until renewal, on %s" (shortDate .Expires)) "tone" "up"))}}
|
||||
{{if .LoginURL}}{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Address" "v" .LoginURL)
|
||||
(dict "k" "Licence expires" "v" (date .Expires)))}}{{else}}{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Licence expires" "v" (date .Expires)))}}{{end}}
|
||||
{{template "label" "Get started"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Sign in" "d" "Use your Vantage account email and password.")
|
||||
(dict "t" "Connect your first server" "d" "Install the Vantage agent on a server and it joins your fleet.")
|
||||
(dict "t" "Add monitors and alerts" "d" "Watch the services that matter and choose who hears when they fail."))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "One password for everything" "text" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too.")}}
|
||||
{{template "small" "We will email you a week before the licence runs out, so you can renew it in one click."}}
|
||||
{{end}}
|
||||
{{define "why"}}you created {{.InstanceName}} in Vantage HQ.{{end}}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
{{define "subject"}}{{.InstanceName}} is ready{{end}}
|
||||
{{define "tone"}}up{{end}}
|
||||
{{define "category"}}Instance{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is ready{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}}
|
||||
{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}}
|
||||
{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" (printf "Sign in to %s" .InstanceName) "url" .LoginURL)}}{{end}}
|
||||
{{template "stats" (list
|
||||
(dict "big" "Free" "label" "Plan" "tone" "accent")
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" (printf "Until renewal, on %s" (shortDate .Expires)) "tone" "up"))}}
|
||||
{{template "label" "Get started"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Sign in" "d" "Use your Vantage account email and password.")
|
||||
(dict "t" "Connect your first server" "d" "Install the Vantage agent on a server and it joins your fleet.")
|
||||
(dict "t" "Add monitors and alerts" "d" "Watch the services that matter and choose who hears when they fail."))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "One password for everything" "text" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too.")}}
|
||||
{{template "small" "We will email you a week before the licence runs out, so you can renew it in one click."}}
|
||||
{{end}}
|
||||
{{define "why"}}you created {{.InstanceName}} in Vantage HQ.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "preheader"}}Join {{.AccountName}} on Vantage. Set a password to accept.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Invitation" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}You have been invited to {{.AccountName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}}
|
||||
{{template "p" "Set your own password and finish joining:"}}
|
||||
{{template "button" (dict "label" "Set password and join" "url" .Link)}}
|
||||
{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage, where the team manages its servers, agents and monitors." .AccountName)}}
|
||||
{{template "button" (dict "label" "Accept invitation" "url" .Link)}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account" "v" .AccountName)
|
||||
(dict "k" "Invited address" "v" .Email)
|
||||
(dict "k" "Link valid for" "v" (plural .TTLHours "hour" "hours")))}}
|
||||
{{template "label" "Joining takes a minute"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Accept the invitation" "d" "Open the link above from this inbox.")
|
||||
(dict "t" "Choose your password" "d" "It is yours alone. Nobody else on the account can see it.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" (printf "Use %s from then on." .Email)))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Not expecting this?" "text" "Ignore it. Nothing happens until you open the link, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone on the {{.AccountName}} account invited {{.Email}}.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}You have been invited to {{.AccountName}} on Vantage{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Invitation" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}You have been invited to {{.AccountName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}}
|
||||
{{template "p" "Set your own password and finish joining:"}}
|
||||
{{template "button" (dict "label" "Set password and join" "url" .Link)}}
|
||||
{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage, where the team manages its servers, agents and monitors." .AccountName)}}
|
||||
{{template "button" (dict "label" "Accept invitation" "url" .Link)}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account" "v" .AccountName)
|
||||
(dict "k" "Invited address" "v" .Email)
|
||||
(dict "k" "Link valid for" "v" (plural .TTLHours "hour" "hours")))}}
|
||||
{{template "label" "Joining takes a minute"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Accept the invitation" "d" "Open the link above from this inbox.")
|
||||
(dict "t" "Choose your password" "d" "It is yours alone. Nobody else on the account can see it.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" (printf "Use %s from then on." .Email)))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Not expecting this?" "text" "Ignore it. Nothing happens until you open the link, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone on the {{.AccountName}} account invited {{.Email}}.{{end}}
|
||||
|
||||
+209
-39
@@ -3,7 +3,7 @@
|
||||
|
||||
Every colour in the email system lives in this file and nowhere else, in the
|
||||
same way no component in web/, site/ or adminsite/ carries a hex. The values
|
||||
are web/app/globals.css's tokens — an email is read before the recipient
|
||||
are web/app/globals.css's tokens - an email is read before the recipient
|
||||
clicks through to the control plane, so the two should not look like
|
||||
different products. They are written as literal hex here because email
|
||||
clients support neither var() nor a reliable prefers-color-scheme, so the
|
||||
@@ -17,102 +17,272 @@
|
||||
--ink-2 #9fb3ca --pend #d6a63f
|
||||
--ink-3 #71879f --well #04101f
|
||||
|
||||
Layout is tables and inline styles throughout, which is not a stylistic
|
||||
choice — it is the only thing Outlook renders predictably.
|
||||
Derived here only, never in a front end: each tone has a tint (15% over
|
||||
--panel) for washes and a border (35% over --panel) for outlines.
|
||||
|
||||
A message file overrides "title", "pill" and "body"; the empty defaults below
|
||||
up #173743 / #245452 pend #2b3539 / #534f3a
|
||||
down #2d2d3d / #573d44 accent #193352 / #284b75
|
||||
|
||||
Layout is tables and inline styles throughout, which is not a stylistic
|
||||
choice - it is the only thing Outlook renders predictably.
|
||||
|
||||
A message file overrides "title", "pill", "body", "category", "preheader"
|
||||
and "why"; its txt file defines "subject" and "tone". The defaults below
|
||||
exist so that a message needing no pill does not have to define one.
|
||||
|
||||
Colour choices that depend on a tone go through pick, which takes the tone
|
||||
and one value per tone (up, down, pend, accent, anything else), so the hex
|
||||
stays in this file while the branching stays out of it.
|
||||
*/ -}}
|
||||
{{- define "title"}}{{end -}}
|
||||
{{- define "pill"}}{{end -}}
|
||||
{{- define "body"}}{{end -}}
|
||||
{{- define "category"}}Account{{end -}}
|
||||
{{- define "preheader"}}{{end -}}
|
||||
{{- define "why"}}it relates to your Vantage account.{{end -}}
|
||||
|
||||
{{- /* p renders one paragraph of body copy. */ -}}
|
||||
{{- define "p" -}}
|
||||
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.6;">{{.}}</p>
|
||||
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.65;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}}
|
||||
{{- /* lead is the first paragraph: larger, brighter, sets the subject. */ -}}
|
||||
{{- define "lead" -}}
|
||||
<p style="margin:0 0 16px;color:#e4ecf6;font-size:15px;line-height:1.6;">{{.}}</p>
|
||||
<p style="margin:0 0 20px;color:#e4ecf6;font-size:16px;line-height:1.6;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* button takes dict "label" "…" "url" "…".
|
||||
{{- /* small is fine print under a section. */ -}}
|
||||
{{- define "small" -}}
|
||||
<p style="margin:0 0 16px;color:#71879f;font-size:12px;line-height:1.6;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* label is an eyebrow over the section that follows it. */ -}}
|
||||
{{- define "label" -}}
|
||||
<p style="margin:24px 0 10px;color:#71879f;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- define "divider" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0 20px;"><tr><td style="height:1px;line-height:1px;font-size:0;background:#1e3855;"> </td></tr></table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* button takes dict "label" "…" "url" "…", and optionally "nofallback".
|
||||
|
||||
The bare URL is printed underneath on purpose: a plain-text-preferring
|
||||
client, a stripped-styles inbox and a forwarded message all lose the
|
||||
anchor, and a verification email whose link cannot be reached is a
|
||||
support ticket. */ -}}
|
||||
support ticket. nofallback is for mailto: links, where it would be noise. */ -}}
|
||||
{{- define "button" -}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 16px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 14px;">
|
||||
<tr>
|
||||
<td style="border-radius:4px;background:#5b9be8;">
|
||||
<a href="{{.url}}" style="display:inline-block;padding:10px 20px;color:#04101f;font-size:14px;font-weight:600;text-decoration:none;">{{.label}}</a>
|
||||
<td style="border-radius:6px;background:#5b9be8;">
|
||||
<a href="{{.url}}" style="display:inline-block;padding:12px 24px;color:#04101f;font-size:14px;font-weight:700;text-decoration:none;border-radius:6px;">{{.label}} →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 20px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
|
||||
Or paste this into your browser:<br>
|
||||
{{- if not .nofallback}}
|
||||
<p style="margin:0 0 22px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
|
||||
Button not working? Paste this into your browser:<br>
|
||||
<a href="{{.url}}" style="color:#5b9be8;text-decoration:none;">{{.url}}</a>
|
||||
</p>
|
||||
{{- end}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /* well shows machine output — a licence blob, an install ID. Mirrors
|
||||
{{- /* well shows machine output - a licence blob, an install ID. Mirrors
|
||||
web/'s --well surface, the floor beneath the ground. */ -}}
|
||||
{{- define "well" -}}
|
||||
<pre style="margin:0 0 20px;padding:14px;background:#04101f;border:1px solid #1e3855;border-radius:4px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
|
||||
<pre style="margin:0 0 20px;padding:14px 16px;background:#04101f;border:1px solid #1e3855;border-radius:6px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* note is a quoted callout, used for a free-text message we did not
|
||||
write ourselves. */ -}}
|
||||
write ourselves. Line breaks are kept: it is somebody's own words. */ -}}
|
||||
{{- define "note" -}}
|
||||
<p style="margin:0 0 20px;padding:12px 14px;background:#102842;border:1px solid #1e3855;border-radius:4px;color:#e4ecf6;font-size:13px;line-height:1.6;">{{.}}</p>
|
||||
<p style="margin:0 0 20px;padding:14px 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;color:#e4ecf6;font-size:13px;line-height:1.65;white-space:pre-wrap;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}}
|
||||
{{- /* rows takes a list of dict "k" "…" "v" "…", and renders them as one
|
||||
panel of facts. */ -}}
|
||||
{{- define "rows" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 8px;border-top:1px solid #1e3855;">
|
||||
{{- range .}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;background:#102842;border:1px solid #1e3855;border-radius:6px;border-collapse:separate;">
|
||||
{{- range $i, $r := .}}
|
||||
<tr>
|
||||
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#71879f;font-size:13px;width:130px;vertical-align:top;">{{.k}}</td>
|
||||
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#e4ecf6;font-size:13px;font-weight:500;">{{.v}}</td>
|
||||
<td style="padding:11px 16px;{{if $i}}border-top:1px solid #172c44;{{end}}color:#71879f;font-size:13px;width:140px;vertical-align:top;">{{$r.k}}</td>
|
||||
<td style="padding:11px 16px 11px 0;{{if $i}}border-top:1px solid #172c44;{{end}}color:#e4ecf6;font-size:13px;font-weight:600;word-break:break-word;">{{$r.v}}</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent". Tone is
|
||||
{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent|none". Tone is
|
||||
never the only signal: the label spells the state out. */ -}}
|
||||
{{- define "chip" -}}
|
||||
{{- $fg := "#5b9be8"}}{{if eq .tone "up"}}{{$fg = "#4fb484"}}{{else if eq .tone "down"}}{{$fg = "#e2705a"}}{{else if eq .tone "pend"}}{{$fg = "#d6a63f"}}{{end -}}
|
||||
<span style="display:inline-block;margin:0 0 12px;padding:4px 11px;border:1px solid {{$fg}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;">{{.label}}</span>
|
||||
{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}}
|
||||
<span style="display:inline-block;padding:4px 11px;background:{{$bg}};border:1px solid {{$bd}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;">{{.label}}</span>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* callout takes dict "tone" "…" "title" "…" "text" "…": one tinted panel
|
||||
for the sentence the reader must not miss. */ -}}
|
||||
{{- define "callout" -}}
|
||||
{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;background:{{$bg}};border:1px solid {{$bd}};border-radius:6px;border-collapse:separate;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;">
|
||||
<p style="margin:0 0 4px;color:{{$fg}};font-size:13px;font-weight:700;">{{.title}}</p>
|
||||
<p style="margin:0;color:#c3d1e1;font-size:13px;line-height:1.6;">{{.text}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* steps takes a list of dict "t" "…" "d" "…". Numbered because the
|
||||
content is always an order the reader follows. */ -}}
|
||||
{{- define "steps" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;">
|
||||
{{- range $i, $s := .}}
|
||||
<tr>
|
||||
<td width="28" style="width:28px;padding:0 12px 14px 0;vertical-align:top;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
<td width="26" height="26" align="center" style="width:26px;height:26px;border-radius:9999px;background:#193352;border:1px solid #284b75;color:#7fb2f0;font-size:12px;font-weight:700;line-height:26px;text-align:center;">{{add $i 1}}</td>
|
||||
</tr></table>
|
||||
</td>
|
||||
<td style="padding:3px 0 14px;vertical-align:top;">
|
||||
<p style="margin:0 0 2px;color:#e4ecf6;font-size:14px;font-weight:600;line-height:1.4;">{{$s.t}}</p>
|
||||
<p style="margin:0;color:#9fb3ca;font-size:13px;line-height:1.55;">{{$s.d}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* stats takes a list of dict "big" "…" "label" "…" "tone" "…": the
|
||||
figures a message exists to state, such as days left. */ -}}
|
||||
{{- define "stats" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;">
|
||||
<tr>
|
||||
{{- range $i, $s := .}}
|
||||
{{- if $i}}<td width="10" style="width:10px;font-size:0;"> </td>{{end}}
|
||||
<td style="padding:14px 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;vertical-align:top;">
|
||||
<p style="margin:0;color:{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#e4ecf6"}};font-size:26px;font-weight:700;line-height:1.15;letter-spacing:-.01em;">{{$s.big}}</p>
|
||||
<p style="margin:4px 0 0;color:#71879f;font-size:12px;line-height:1.4;">{{$s.label}}</p>
|
||||
</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* timeline takes a list of dict "label" "…" "date" "…" "state"
|
||||
"done|now|next" "tone" "…". It is the licence lifecycle, drawn as
|
||||
segments so the reader sees where they are in it. */ -}}
|
||||
{{- define "timeline" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 22px;table-layout:fixed;">
|
||||
<tr>
|
||||
{{- range $i, $s := .}}
|
||||
{{- if $i}}<td width="6" style="width:6px;font-size:0;"> </td>{{end}}
|
||||
<td style="vertical-align:top;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="height:4px;line-height:4px;font-size:0;border-radius:2px;background:{{if eq $s.state "now"}}{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#5b9be8"}}{{else if eq $s.state "done"}}#3d5673{{else}}#1e3855{{end}};"> </td></tr></table>
|
||||
<p style="margin:10px 0 2px;color:{{if eq $s.state "now"}}{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#7fb2f0"}}{{else}}#71879f{{end}};font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;">{{$s.label}}</p>
|
||||
<p style="margin:0;color:{{if eq $s.state "next"}}#9fb3ca{{else}}#e4ecf6{{end}};font-size:13px;font-weight:600;line-height:1.4;">{{$s.date}}</p>
|
||||
</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* findings takes a list of VulnDigestRow: one line per finding, the
|
||||
severity as a chip, the advisory linked when its ID says where it is. */ -}}
|
||||
{{- define "findings" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;border-collapse:separate;">
|
||||
{{- range $i, $r := .}}
|
||||
<tr>
|
||||
<td style="padding:12px 16px;{{if $i}}border-top:1px solid #172c44;{{end}}vertical-align:top;">
|
||||
<p style="margin:0 0 6px;">{{template "chip" (dict "label" $r.Severity "tone" (sevTone $r.Severity))}} {{with advisoryURL $r.CVEID}}<a href="{{.}}" style="color:#7fb2f0;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:13px;font-weight:700;text-decoration:none;">{{$r.CVEID}}</a>{{else}}<span style="color:#e4ecf6;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:13px;font-weight:700;">{{$r.CVEID}}</span>{{end}}</p>
|
||||
<p style="margin:0;color:#9fb3ca;font-size:13px;line-height:1.5;"><span style="color:#e4ecf6;font-weight:600;">{{$r.PackageName}}</span> on {{$r.ServerName}}</p>
|
||||
</td>
|
||||
<td align="right" style="padding:12px 16px 12px 0;{{if $i}}border-top:1px solid #172c44;{{end}}vertical-align:top;white-space:nowrap;font-size:12px;font-weight:600;">
|
||||
{{- if $r.FixedIn}}<span style="color:#4fb484;">Fixed in {{$r.FixedIn}}</span>{{else}}<span style="color:#d6a63f;">No fix yet</span>{{end -}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- $tone := tone -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#071628;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#071628;padding:32px 12px;">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{{template "title" .}}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#071628;" bgcolor="#071628">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:#071628;font-size:1px;line-height:1px;">{{template "preheader" .}} ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#071628" style="background:#071628;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:#0d2138;border:1px solid #1e3855;border-radius:4px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<tr><td style="height:3px;background:#5b9be8;"></td></tr>
|
||||
<td align="center" style="padding:32px 12px 40px;">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
|
||||
<tr>
|
||||
<td style="padding:26px 28px 8px;">
|
||||
<span style="font-size:16px;font-weight:700;letter-spacing:-.01em;color:#7fb2f0;">Vantage</span>
|
||||
<td style="padding:0 4px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="vertical-align:middle;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
<td width="28" height="28" align="center" style="width:28px;height:28px;border-radius:7px;background:#5b9be8;color:#04101f;font-size:15px;font-weight:800;line-height:28px;text-align:center;">V</td>
|
||||
<td style="padding-left:10px;color:#e4ecf6;font-size:16px;font-weight:700;letter-spacing:-.01em;">Vantage</td>
|
||||
</tr></table>
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;color:#71879f;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">{{template "category" .}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px 28px 26px;">
|
||||
{{template "pill" .}}
|
||||
<h1 style="margin:2px 0 14px;font-size:20px;font-weight:700;line-height:1.3;color:#e4ecf6;">{{template "title" .}}</h1>
|
||||
{{template "body" .}}
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d2138;border:1px solid #1e3855;border-radius:10px;border-collapse:separate;overflow:hidden;">
|
||||
<tr><td style="height:4px;line-height:4px;font-size:0;background:{{pick $tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#5b9be8"}};border-radius:10px 10px 0 0;"> </td></tr>
|
||||
<tr>
|
||||
<td style="padding:28px 32px 8px;background-color:#0d2138;background-image:linear-gradient(180deg,{{pick $tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#193352"}} 0%,#0d2138 100%);">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 16px;"><tr>
|
||||
<td width="40" height="40" align="center" style="width:40px;height:40px;border-radius:9999px;background:{{pick $tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#193352"}};border:1px solid {{pick $tone "#245452" "#573d44" "#534f3a" "#284b75" "#284b75"}};color:{{pick $tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#7fb2f0"}};font-size:18px;font-weight:800;line-height:40px;text-align:center;">{{pick $tone "✓" "!" "!" "→" "→"}}</td>
|
||||
<td style="padding-left:12px;vertical-align:middle;">{{template "pill" .}}</td>
|
||||
</tr></table>
|
||||
<h1 style="margin:0 0 6px;font-size:23px;font-weight:700;line-height:1.3;letter-spacing:-.01em;color:#e4ecf6;">{{template "title" .}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 32px 12px;">
|
||||
{{template "body" .}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 32px 18px;border-top:1px solid #1e3855;background:#0a1c30;border-radius:0 0 10px 10px;">
|
||||
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.6;"><span style="color:#9fb3ca;font-weight:600;">Why you got this:</span> {{template "why" .}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:14px 28px;border-top:1px solid #1e3855;background:#04101f;">
|
||||
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.5;">Sent by Vantage · infrastructure control plane</p>
|
||||
<td align="center" style="padding:22px 8px 0;">
|
||||
{{- with hq}}
|
||||
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;">
|
||||
<a href="{{.}}" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Vantage HQ</a>
|
||||
<span style="color:#3d5673;"> · </span>
|
||||
<a href="{{.}}/instances" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Instances</a>
|
||||
<span style="color:#3d5673;"> · </span>
|
||||
<a href="{{.}}/billing" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Billing</a>
|
||||
<span style="color:#3d5673;"> · </span>
|
||||
<a href="{{.}}/settings" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Settings</a>
|
||||
</p>
|
||||
{{- end}}
|
||||
<p style="margin:0 0 4px;color:#71879f;font-size:12px;line-height:1.6;">Vantage · infrastructure control plane</p>
|
||||
<p style="margin:0;color:#4f6680;font-size:11px;line-height:1.6;">© {{year}} Vantage. This is an automated message about your service.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
{{- /*
|
||||
The plain-text counterpart of layout.html.tmpl.
|
||||
|
||||
It defines the same helper names — p, lead, button, well, note, rows, chip —
|
||||
so a message's txt file reads as the same document as its html one, and a
|
||||
helper added on one side is obvious by its absence on the other.
|
||||
It defines the same helper names - p, lead, small, label, divider, button,
|
||||
well, note, rows, chip, callout, steps, stats, timeline - so a message's txt
|
||||
file reads as the same document as its html one, and a helper added on one
|
||||
side is obvious by its absence on the other.
|
||||
|
||||
"subject" is defined here rather than in the HTML file: html/template would
|
||||
escape an ampersand in an instance name, and mail clients show subject lines
|
||||
verbatim.
|
||||
"subject" and "tone" are defined here rather than in the HTML file:
|
||||
html/template would escape an ampersand in an instance name, and mail
|
||||
clients show subject lines verbatim. tone is read once per render and handed
|
||||
to both layouts through the tone func.
|
||||
*/ -}}
|
||||
{{- define "subject"}}Vantage{{end -}}
|
||||
{{- define "tone"}}accent{{end -}}
|
||||
{{- define "title"}}{{end -}}
|
||||
{{- define "pill"}}{{end -}}
|
||||
{{- define "body"}}{{end -}}
|
||||
{{- define "category"}}Account{{end -}}
|
||||
{{- define "why"}}it relates to your Vantage account.{{end -}}
|
||||
|
||||
{{- define "p"}}{{.}}
|
||||
|
||||
@@ -20,8 +25,16 @@
|
||||
{{- define "lead"}}{{.}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "button"}}{{.label}}:
|
||||
{{- define "small"}}{{.}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "label"}}{{upper .}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "divider"}}----------------------------------------
|
||||
|
||||
{{end -}}
|
||||
{{- define "button"}}{{.label}}:
|
||||
{{.url}}
|
||||
|
||||
{{end -}}
|
||||
@@ -31,16 +44,38 @@
|
||||
{{- define "note"}}{{.}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "rows"}}{{range .}}{{.k}}: {{.v}}
|
||||
{{- define "rows"}}{{range .}} {{.k}}: {{.v}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "chip"}}[{{upper .label}}]
|
||||
{{- define "chip"}}[{{upper .label}}]{{end -}}
|
||||
{{- define "callout"}}>> {{.title}}
|
||||
{{.text}}
|
||||
|
||||
{{end -}}
|
||||
VANTAGE
|
||||
{{- define "steps"}}{{range $i, $s := .}} {{add $i 1}}. {{$s.t}}
|
||||
{{$s.d}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "stats"}}{{range .}} {{.label}}: {{.big}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "timeline"}}{{range .}} {{if eq .state "done"}}[x]{{else if eq .state "now"}}[>]{{else}}[ ]{{end}} {{.label}}: {{.date}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "findings"}}{{range .}} - {{.CVEID}} [{{upper .Severity}}] {{.PackageName}} on {{.ServerName}}, {{if .FixedIn}}fixed in {{.FixedIn}}{{else}}no fix published yet{{end}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
VANTAGE · {{template "category" .}}
|
||||
|
||||
{{template "pill" .}}
|
||||
{{template "title" .}}
|
||||
========================================
|
||||
|
||||
{{template "body" .}}
|
||||
--
|
||||
Sent by Vantage · infrastructure control plane
|
||||
----------------------------------------
|
||||
Why you got this: {{template "why" .}}
|
||||
{{with hq}}
|
||||
Vantage HQ: {{.}}
|
||||
Billing: {{.}}/billing
|
||||
{{end}}
|
||||
Vantage · infrastructure control plane
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "title"}}Your licence key{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Paste this key into Settings, Licence on {{.InstanceName}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Licence key" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Your licence key for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your licence for %s is below." .InstanceName)}}
|
||||
{{template "p" "Paste it into Settings → Licence on your Vantage install:"}}
|
||||
{{template "lead" (printf "Here is the signed licence for %s. It activates that install and no other." .InstanceName)}}
|
||||
{{template "label" "Install it in three steps"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open your Vantage install" "d" "Sign in as an administrator.")
|
||||
(dict "t" "Go to Settings → Licence" "d" "The current licence and its expiry are shown there.")
|
||||
(dict "t" "Paste the key and save" "d" "Copy the whole block below, from the first character to the last."))}}
|
||||
{{template "label" "Licence key"}}
|
||||
{{template "well" .Blob}}
|
||||
{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Issued" "v" (stamp .Issued)))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "Safe to keep in email" "text" "The licence is signed public data, not a secret. It is useless on any instance other than the one it names.")}}
|
||||
{{end}}
|
||||
{{define "why"}}a licence was issued for {{.InstanceName}}.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}Your Vantage licence key{{end}}
|
||||
{{define "title"}}Your licence key{{end}}
|
||||
{{define "subject"}}Your Vantage licence key for {{.InstanceName}}{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Licence key" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Your licence key for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your licence for %s is below." .InstanceName)}}
|
||||
{{template "p" "Paste it into Settings > Licence on your Vantage install:"}}
|
||||
{{template "lead" (printf "Here is the signed licence for %s. It activates that install and no other." .InstanceName)}}
|
||||
{{template "label" "Install it in three steps"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open your Vantage install" "d" "Sign in as an administrator.")
|
||||
(dict "t" "Go to Settings > Licence" "d" "The current licence and its expiry are shown there.")
|
||||
(dict "t" "Paste the key and save" "d" "Copy the whole block below, from the first character to the last."))}}
|
||||
{{template "label" "Licence key"}}
|
||||
{{template "well" .Blob}}
|
||||
{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Issued" "v" (stamp .Issued)))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "Safe to keep in email" "text" "The licence is signed public data, not a secret. It is useless on any instance other than the one it names.")}}
|
||||
{{end}}
|
||||
{{define "why"}}a licence was issued for {{.InstanceName}}.{{end}}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{{define "category"}}Monitoring{{end}}
|
||||
{{define "preheader"}}{{.Type}} check {{if .Down}}is failing{{else}}is passing again{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}}
|
||||
{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}} {{if .Down}}is down{{else}}has recovered{{end}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "p" (printf "%s check" .Type)}}
|
||||
{{if .Message}}{{template "note" .Message}}{{end}}
|
||||
{{if .Down}}{{template "lead" (printf "The %s check for %s started failing at %s." .Type .MonitorName (stamp .Time))}}{{else}}{{template "lead" (printf "The %s check for %s is passing again as of %s." .Type .MonitorName (stamp .Time))}}{{end}}
|
||||
{{if .Message}}{{template "label" "Check output"}}{{template "note" .Message}}{{end}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Monitor" "v" .MonitorName)
|
||||
(dict "k" "Check type" "v" .Type)
|
||||
(dict "k" "Status" "v" (printf "%s → %s" .OldStatus .NewStatus))
|
||||
(dict "k" "Type" "v" .Type)
|
||||
(dict "k" "Time" "v" (stamp .Time)))}}
|
||||
(dict "k" "Changed at" "v" (stamp .Time)))}}
|
||||
{{if .Down}}{{template "callout" (dict "tone" "down" "title" "What to do" "text" "Open the monitor in your control plane to see recent samples and response times. You will get another email as soon as it recovers.")}}{{else}}{{template "callout" (dict "tone" "up" "title" "All clear" "text" "No action is needed. You will be emailed again if the check fails.")}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}this address is on an email notification channel for this monitor.{{end}}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{{define "subject"}}[Vantage] {{.MonitorName}} ({{.Type}}) {{if .Down}}is DOWN{{else}}recovered{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}}
|
||||
{{define "tone"}}{{if .Down}}down{{else}}up{{end}}{{end}}
|
||||
{{define "category"}}Monitoring{{end}}
|
||||
{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}} {{if .Down}}is down{{else}}has recovered{{end}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "p" (printf "%s check" .Type)}}
|
||||
{{if .Message}}{{template "note" .Message}}{{end}}
|
||||
{{if .Down}}{{template "lead" (printf "The %s check for %s started failing at %s." .Type .MonitorName (stamp .Time))}}{{else}}{{template "lead" (printf "The %s check for %s is passing again as of %s." .Type .MonitorName (stamp .Time))}}{{end}}
|
||||
{{if .Message}}{{template "label" "Check output"}}{{template "note" .Message}}{{end}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Monitor" "v" .MonitorName)
|
||||
(dict "k" "Check type" "v" .Type)
|
||||
(dict "k" "Status" "v" (printf "%s -> %s" .OldStatus .NewStatus))
|
||||
(dict "k" "Type" "v" .Type)
|
||||
(dict "k" "Time" "v" (stamp .Time)))}}
|
||||
(dict "k" "Changed at" "v" (stamp .Time)))}}
|
||||
{{if .Down}}{{template "callout" (dict "tone" "down" "title" "What to do" "text" "Open the monitor in your control plane to see recent samples and response times. You will get another email as soon as it recovers.")}}{{else}}{{template "callout" (dict "tone" "up" "title" "All clear" "text" "No action is needed. You will be emailed again if the check fails.")}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}this address is on an email notification channel for this monitor.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "preheader"}}Nothing has changed yet. Update your payment method to avoid interruption.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Payment failed for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "A payment for %s failed." .InstanceName)}}
|
||||
{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}}
|
||||
{{template "lead" (printf "A payment for %s did not go through. The card will be retried automatically." .InstanceName)}}
|
||||
{{template "callout" (dict "tone" "up" "title" "Your instance is unaffected" "text" "Servers, agents and monitors keep running while the payment is retried. The licence is untouched.")}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Update payment method" "url" (printf "%s/billing" .PortalURL))}}{{end}}
|
||||
{{template "label" "How to fix it"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open billing in Vantage HQ" "d" "Sign in and go to Billing.")
|
||||
(dict "t" "Update your payment method" "d" "Add a new card, or correct the details of the current one.")
|
||||
(dict "t" "That is all" "d" "The next retry uses the updated details."))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Payment" "v" "Failed, retrying")
|
||||
(dict "k" "Licence" "v" "Unaffected"))}}
|
||||
{{end}}
|
||||
{{define "why"}}a payment for the {{.InstanceName}} subscription failed.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}Payment failed for your Vantage subscription{{end}}
|
||||
{{define "subject"}}Payment failed for your Vantage subscription ({{.InstanceName}}){{end}}
|
||||
{{define "tone"}}pend{{end}}
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Payment failed for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "A payment for %s failed." .InstanceName)}}
|
||||
{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}}
|
||||
{{template "lead" (printf "A payment for %s did not go through. The card will be retried automatically." .InstanceName)}}
|
||||
{{template "callout" (dict "tone" "up" "title" "Your instance is unaffected" "text" "Servers, agents and monitors keep running while the payment is retried. The licence is untouched.")}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Update payment method" "url" (printf "%s/billing" .PortalURL))}}{{end}}
|
||||
{{template "label" "How to fix it"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open billing in Vantage HQ" "d" "Sign in and go to Billing.")
|
||||
(dict "t" "Update your payment method" "d" "Add a new card, or correct the details of the current one.")
|
||||
(dict "t" "That is all" "d" "The next retry uses the updated details."))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Payment" "v" "Failed, retrying")
|
||||
(dict "k" "Licence" "v" "Unaffected"))}}
|
||||
{{end}}
|
||||
{{define "why"}}a payment for the {{.InstanceName}} subscription failed.{{end}}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Licence extended to {{date .Expires}}. Nothing else changes.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is renewed{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}}
|
||||
{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" "remaining on the licence" "tone" "up")
|
||||
(dict "big" (shortDate .Expires) "label" "new expiry date" "tone" "none"))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Renewed" "date" "Today" "state" "done")
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "up")
|
||||
(dict "label" "Reminder" "date" "A week before" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Plan" "v" "Free")
|
||||
(dict "k" "Licence expires" "v" (date .Expires)))}}
|
||||
{{template "p" "Nothing else changes. Your servers, agents and monitors carry on as they were."}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} was renewed.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "subject"}}{{.InstanceName}} renewed{{end}}
|
||||
{{define "subject"}}{{.InstanceName}} renewed until {{shortDate .Expires}}{{end}}
|
||||
{{define "tone"}}up{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is renewed{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}}
|
||||
{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Renewed" "date" "Today" "state" "done")
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "up")
|
||||
(dict "label" "Reminder" "date" "A week before" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Plan" "v" "Free")
|
||||
(dict "k" "Licence expires" "v" (date .Expires))
|
||||
(dict "k" "Remaining" "v" (plural (daysUntil .Expires) "day" "days")))}}
|
||||
{{template "p" "Nothing else changes. Your servers, agents and monitors carry on as they were."}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} was renewed.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "preheader"}}One click to confirm {{.Email}}. The link expires in {{.TTLHours}} hours.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Confirm your email address{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Confirm this address to finish setting up your Vantage account."}}
|
||||
{{template "lead" (printf "Welcome to Vantage. Confirm that %s is your address and your account is ready to use." .Email)}}
|
||||
{{template "button" (dict "label" "Confirm email address" "url" .Link)}}
|
||||
{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}}
|
||||
{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account email" "v" .Email)
|
||||
(dict "k" "Link expires" "v" (stamp .Expires))
|
||||
(dict "k" "Can be used" "v" "Once"))}}
|
||||
{{template "label" "What happens next"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Confirm your address" "d" "Open the link above. It works once, then it is spent.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" "Use this email address and the password you chose at sign-up.")
|
||||
(dict "t" "Create your first instance" "d" "It starts on a Free licence you can renew in one click."))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Did not sign up?" "text" "Ignore this email. Nothing is activated until the link is opened, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone signed up for Vantage with {{.Email}}.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}Verify your Vantage account{{end}}
|
||||
{{define "subject"}}Confirm your email to finish setting up Vantage{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Confirm your email address{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Confirm this address to finish setting up your Vantage account."}}
|
||||
{{template "lead" (printf "Welcome to Vantage. Confirm that %s is your address and your account is ready to use." .Email)}}
|
||||
{{template "button" (dict "label" "Confirm email address" "url" .Link)}}
|
||||
{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}}
|
||||
{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account email" "v" .Email)
|
||||
(dict "k" "Link expires" "v" (stamp .Expires))
|
||||
(dict "k" "Can be used" "v" "Once"))}}
|
||||
{{template "label" "What happens next"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Confirm your address" "d" "Open the link above. It works once, then it is spent.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" "Use this email address and the password you chose at sign-up.")
|
||||
(dict "t" "Create your first instance" "d" "It starts on a Free licence you can renew in one click."))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Did not sign up?" "text" "Ignore this email. Nothing is activated until the link is opened, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone signed up for Vantage with {{.Email}}.{{end}}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{{define "title"}}New vulnerabilities detected{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}}
|
||||
{{define "category"}}Security{{end}}
|
||||
{{define "preheader"}}{{.Summary}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" (printf "Highest: %s" .TopSeverity) "tone" (sevTone .TopSeverity))}}{{end}}
|
||||
{{define "title"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" .Summary}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "New findings" "v" .Count))}}
|
||||
{{range .Rows}}
|
||||
{{if .FixedIn}}{{template "well" (printf "%s (%s) — %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) — %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}}
|
||||
{{end}}
|
||||
{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}}
|
||||
{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
|
||||
{{template "stats" (list
|
||||
(dict "big" .Count "label" "new findings" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .TopSeverity "label" "highest severity" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .DBAge "label" "database age" "tone" "none"))}}
|
||||
{{template "label" "Findings"}}
|
||||
{{template "findings" .Rows}}
|
||||
{{if .More}}{{template "small" (printf "Plus %d more not listed here. The full list is on the Vulnerabilities page of your control plane." .More)}}{{end}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "What to do" "text" "Update each affected package to the fixed version shown. Findings marked No fix yet have no vendor patch published so far.")}}
|
||||
{{template "small" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
|
||||
{{end}}
|
||||
{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}}
|
||||
{{define "title"}}New vulnerabilities detected{{end}}
|
||||
{{define "pill"}}{{.TopSeverity}}{{end}}
|
||||
{{define "subject"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}} (highest: {{.TopSeverity}}){{end}}
|
||||
{{define "tone"}}{{sevTone .TopSeverity}}{{end}}
|
||||
{{define "category"}}Security{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" (printf "Highest: %s" .TopSeverity) "tone" (sevTone .TopSeverity))}}{{end}}
|
||||
{{define "title"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" .Summary}}
|
||||
|
||||
{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}}
|
||||
{{template "stats" (list
|
||||
(dict "big" .Count "label" "New findings" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .TopSeverity "label" "Highest severity" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .DBAge "label" "Database age" "tone" "none"))}}
|
||||
{{template "label" "Findings"}}
|
||||
{{template "findings" .Rows}}
|
||||
{{if .More}}{{template "small" (printf "Plus %d more not listed here. The full list is on the Vulnerabilities page of your control plane." .More)}}{{end}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "What to do" "text" "Update each affected package to the fixed version shown. Findings marked no fix published yet have no vendor patch so far.")}}
|
||||
{{template "small" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
|
||||
{{end}}
|
||||
{{if .More}}...and {{.More}} more.{{end}}
|
||||
|
||||
Scanned against vulnerability database pulled {{.DBAge}} ago.
|
||||
{{end}}
|
||||
{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ type VulnDigestRow struct {
|
||||
PackageName string
|
||||
ServerName string
|
||||
// FixedIn empty means no vendor fix has been published, which the template
|
||||
// says explicitly rather than leaving blank — it is a real state, not
|
||||
// says explicitly rather than leaving blank - it is a real state, not
|
||||
// missing data.
|
||||
FixedIn string
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type VulnDigestRow struct {
|
||||
type VulnDigest struct {
|
||||
InstanceName string
|
||||
// Count is every newly opened finding in the batch, which may exceed
|
||||
// len(Rows) — Rows is capped and More carries the remainder.
|
||||
// len(Rows) - Rows is capped and More carries the remainder.
|
||||
Count int
|
||||
TopSeverity string
|
||||
Summary string
|
||||
|
||||
+9
-1
@@ -13,7 +13,7 @@ import (
|
||||
// keys, workflows, monitors and secrets. It is the unit a licence attaches to.
|
||||
//
|
||||
// A paying customer may hold several. That grouping is called an Account and
|
||||
// lives only in the admin control plane — this service never sees it.
|
||||
// lives only in the admin control plane - this service never sees it.
|
||||
type Instance struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
@@ -31,4 +31,12 @@ type Instance struct {
|
||||
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
|
||||
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
|
||||
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
|
||||
|
||||
// LockedAt and PurgeAfter are written by vantage-admin's cloudprov when the
|
||||
// account that owns this instance is under a dispute. LockedAt makes the
|
||||
// control plane refuse the instance everywhere; PurgeAfter, set only once a
|
||||
// dispute has failed, authorises the reaper to delete it. Both absent is the
|
||||
// normal state, and nothing but admin ever sets them.
|
||||
LockedAt *time.Time `bson:"locked_at,omitempty" json:"locked_at,omitempty"`
|
||||
PurgeAfter *time.Time `bson:"purge_after,omitempty" json:"purge_after,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestInstanceLockFieldsBSON(t *testing.T) {
|
||||
at := time.Date(2026, 9, 10, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
raw, err := bson.Marshal(Instance{InstanceID: "i1", LockedAt: &at, PurgeAfter: &at})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var set bson.M
|
||||
if err := bson.Unmarshal(raw, &set); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"locked_at", "purge_after"} {
|
||||
if _, ok := set[k]; !ok {
|
||||
t.Errorf("%s missing from %v", k, set)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err = bson.Marshal(Instance{InstanceID: "i1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var unset bson.M
|
||||
if err := bson.Unmarshal(raw, &unset); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"locked_at", "purge_after"} {
|
||||
if _, ok := unset[k]; ok {
|
||||
t.Errorf("%s written for an unlocked instance: %v", k, unset)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -31,7 +31,7 @@ type Settings struct {
|
||||
|
||||
// LocalLoginEnabled is a pointer because it is absent on every settings
|
||||
// document written before this feature existed, and a plain bool would read
|
||||
// absent as disabled — turning off password login for the entire fleet at
|
||||
// absent as disabled - turning off password login for the entire fleet at
|
||||
// upgrade. Nil means enabled.
|
||||
LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
|
||||
|
||||
@@ -43,7 +43,7 @@ type Settings struct {
|
||||
// APITokenMaxDays caps how long a newly created API token may live.
|
||||
//
|
||||
// A pointer for the same reason the retention fields are: absent must mean
|
||||
// the default, and the default here is no cap at all — never-expire tokens
|
||||
// the default, and the default here is no cap at all - never-expire tokens
|
||||
// are allowed until an instance decides otherwise, so an upgrade changes
|
||||
// nothing. Nil or 0 is no cap. A positive value refuses both a longer
|
||||
// expiry and a token with no expiry.
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ const (
|
||||
AuthOIDC = "oidc"
|
||||
// AuthHQ marks a user projected from a Vantage HQ account. Its role,
|
||||
// password and existence are owned by HQ, and the instance API refuses to
|
||||
// change any of them locally — a role editable in two places is a role with
|
||||
// change any of them locally - a role editable in two places is a role with
|
||||
// two answers.
|
||||
AuthHQ = "hq"
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ message SyncResponse {
|
||||
|
||||
// collect_packages tells the agent whether this instance's licence grants
|
||||
// vulnerability scanning. False means do not collect at all: no gRPC body,
|
||||
// no document, no storage. The server re-checks on ReportPackages — this
|
||||
// no document, no storage. The server re-checks on ReportPackages - this
|
||||
// flag is the optimisation, the server check is the boundary.
|
||||
//
|
||||
// Absent reads as false, which is the safe direction: an old server that
|
||||
@@ -246,7 +246,7 @@ message ServerCommand {
|
||||
// Behind an L7 proxy the agent's HTTP/2 connection terminates at the proxy, so
|
||||
// keepalive pings are answered by the proxy whether or not the server behind it
|
||||
// is still there. A pod that dies leaves the agent blocked in Recv on a stream
|
||||
// that will never produce another message and never error — commands are
|
||||
// that will never produce another message and never error - commands are
|
||||
// dispatched into it and silently lost. Only traffic that originates at the
|
||||
// server itself distinguishes a live stream from an orphaned one.
|
||||
message PingCmd {
|
||||
|
||||
@@ -30,8 +30,8 @@ func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*mode
|
||||
// created before payment and provisioning happens on the confirmed-payment
|
||||
// webhook. Provisioning with the placeholder's own ID keeps the id stable, so
|
||||
// the subscription's custom_data never points at a rewritten row and later
|
||||
// webhooks still resolve it. If an instance with this ID already exists — a
|
||||
// webhook retried after a partial provision — it is returned as-is rather than
|
||||
// webhooks still resolve it. If an instance with this ID already exists - a
|
||||
// webhook retried after a partial provision - it is returned as-is rather than
|
||||
// duplicated.
|
||||
//
|
||||
// The count-then-insert loop is racy on its own. It is safe only because
|
||||
@@ -92,7 +92,7 @@ var ErrSlugTaken = errors.New("slug taken")
|
||||
// the slug it holds now.
|
||||
//
|
||||
// It returns the current slug unchanged when the name still derives to it, so a
|
||||
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
|
||||
// cosmetic edit - capitalisation, punctuation, a trailing "Ltd." - is not a move
|
||||
// and cannot collide with the instance's own slug.
|
||||
func RenameSlug(name, currentSlug string) (string, error) {
|
||||
base, err := BaseSlug(name)
|
||||
@@ -110,13 +110,13 @@ func RenameSlug(name, currentSlug string) (string, error) {
|
||||
// It returns the name and slug the control plane held BEFORE the write, and
|
||||
// those are the only correct values to unwind with. The caller's own copy of the
|
||||
// instance may be stale, and admin's copy stores slug with `omitempty`, so an
|
||||
// unwind driven from there can write an empty slug — which either mis-restores
|
||||
// unwind driven from there can write an empty slug - which either mis-restores
|
||||
// the tenant host or trips the unique index against every other slugless row.
|
||||
//
|
||||
// The count-then-update is racy on its own, and is safe for the same reason
|
||||
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
|
||||
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
|
||||
// to retry with — the caller asked for one specific name — so it becomes
|
||||
// to retry with - the caller asked for one specific name - so it becomes
|
||||
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
|
||||
// index.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (inst *models.Instance, prevName, prevSlug string, err error) {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// plane and sitesvc.
|
||||
//
|
||||
// These rules used to be duplicated: the control plane owned one copy and
|
||||
// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried
|
||||
// sitesvc mirrored it by hand. The copies had already drifted - sitesvc retried
|
||||
// on a lost slug race while the control plane returned an error. This package
|
||||
// is the single definition; neither service may reimplement any of it.
|
||||
package provision
|
||||
|
||||
Reference in New Issue
Block a user