You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/07/22 06:51:55 UTC
(airavata-custos) 18/29: Send an account-ready email when an access request is approved
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch pearc26
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit e8a1afd2bec22e9de4627c7b61423c64a932d6a5
Author: lahiruj <la...@gmail.com>
AuthorDate: Thu Jul 16 12:28:14 2026 -0400
Send an account-ready email when an access request is approved
---
cmd/server/main.go | 16 +++
config/custos.yaml | 11 ++
go.mod | 12 +-
go.sum | 24 ++--
internal/config/config.go | 14 ++
internal/email/live_send_test.go | 48 +++++++
internal/email/mailer.go | 166 +++++++++++++++++++++++
internal/email/mailer_test.go | 81 +++++++++++
internal/email/templates/account-ready.html.tmpl | 127 +++++++++++++++++
internal/email/templates/account-ready.txt.tmpl | 23 ++++
pkg/service/access_request.go | 57 ++++++--
pkg/service/service.go | 11 ++
12 files changed, 560 insertions(+), 30 deletions(-)
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 5a517a669..f5c9e90a3 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -37,6 +37,7 @@ import (
"github.com/apache/airavata-custos/internal/config"
"github.com/apache/airavata-custos/internal/connectors"
"github.com/apache/airavata-custos/internal/db"
+ "github.com/apache/airavata-custos/internal/email"
"github.com/apache/airavata-custos/internal/server"
"github.com/apache/airavata-custos/internal/tracing"
"github.com/apache/airavata-custos/pkg/events"
@@ -147,6 +148,21 @@ func run() error {
}
svc.SetIdentityCacheTTL(cfg.Core.Auth.CacheTTL)
+ mailer := email.New(email.Config{
+ Host: cfg.Core.Email.SMTPHost,
+ Port: cfg.Core.Email.SMTPPort,
+ Username: cfg.Core.Email.Username,
+ Password: cfg.Core.Email.Password,
+ From: cfg.Core.Email.From,
+ SiteName: cfg.Core.Email.SiteName,
+ PortalURL: cfg.Core.Email.PortalURL,
+ ClusterHost: cfg.Core.Email.ClusterHost,
+ })
+ if _, isNoop := mailer.(email.Noop); isNoop {
+ slog.Info("notification mail disabled: relay settings incomplete")
+ }
+ svc.SetMailer(mailer)
+
// identity.Middleware sits in front of the router-backed server, so every
// gated route sees a verified caller + privilege set on ctx.
authed := identity.Middleware(verifier, svc, router.PublicPaths(), router.TokenPathMatcher(), srv)
diff --git a/config/custos.yaml b/config/custos.yaml
index b41ea3055..defeb3e5e 100644
--- a/config/custos.yaml
+++ b/config/custos.yaml
@@ -27,6 +27,17 @@ core:
audience: "${OIDC_AUDIENCE}"
cache_ttl: "30s"
+ # Notification mail; sending stays disabled while any value is unset.
+ email:
+ smtp_host: "smtp.gmail.com"
+ smtp_port: 587
+ username: "${EMAIL_HOST_USER}"
+ password: "${EMAIL_HOST_PASSWORD}"
+ from: "${EMAIL_HOST_USER}"
+ site_name: "${EMAIL_SITE_NAME}"
+ portal_url: "${EMAIL_PORTAL_URL}"
+ cluster_host: "${EMAIL_CLUSTER_HOST}"
+
connectors:
slurm-mapper:
type: "slurm-association-mapper"
diff --git a/go.mod b/go.mod
index ee14e4a0e..58dc84679 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/stretchr/testify v1.11.1
+ github.com/wneessen/go-mail v0.8.1
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
@@ -45,12 +46,13 @@ require (
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
- golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/mod v0.37.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sync v0.18.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
- golang.org/x/text v0.31.0 // indirect
- golang.org/x/tools v0.38.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/tools v0.47.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
diff --git a/go.sum b/go.sum
index 96b01fe44..c8046f90c 100644
--- a/go.sum
+++ b/go.sum
@@ -132,6 +132,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
+github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
+github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -150,25 +152,27 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/config/config.go b/internal/config/config.go
index 8f92ab953..9fe6f5f6f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -45,6 +45,20 @@ type CoreConfig struct {
API APIConfig `yaml:"api"`
LogLevel string `yaml:"log_level"`
Auth AuthConfig `yaml:"auth"`
+ Email EmailConfig `yaml:"email"`
+}
+
+// EmailConfig carries the SMTP relay and the deployment identity rendered
+// into notification mail. All optional; incomplete settings disable sending.
+type EmailConfig struct {
+ SMTPHost string `yaml:"smtp_host"`
+ SMTPPort int `yaml:"smtp_port"`
+ Username string `yaml:"username"`
+ Password string `yaml:"password"`
+ From string `yaml:"from"`
+ SiteName string `yaml:"site_name"`
+ PortalURL string `yaml:"portal_url"`
+ ClusterHost string `yaml:"cluster_host"`
}
type DatabaseConfig struct {
diff --git a/internal/email/live_send_test.go b/internal/email/live_send_test.go
new file mode 100644
index 000000000..8f6949ba4
--- /dev/null
+++ b/internal/email/live_send_test.go
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build livemail
+
+package email
+
+import (
+ "context"
+ "os"
+ "testing"
+)
+
+func TestLiveSend(t *testing.T) {
+ m := New(Config{
+ Host: "smtp.gmail.com", Port: 587,
+ Username: os.Getenv("EMAIL_HOST_USER"), Password: os.Getenv("EMAIL_HOST_PASSWORD"),
+ SiteName: os.Getenv("EMAIL_SITE_NAME"), PortalURL: os.Getenv("EMAIL_PORTAL_URL"), ClusterHost: os.Getenv("EMAIL_CLUSTER_HOST"),
+ })
+ if _, ok := m.(Noop); ok {
+ t.Skip("relay not configured")
+ }
+ to := os.Getenv("EMAIL_TEST_TO")
+ if to == "" {
+ t.Skip("EMAIL_TEST_TO not set")
+ }
+ err := m.SendAccountReady(context.Background(), to, AccountReadyData{
+ Username: "nexus-ljayathilake", FirstName: "Lahiru", ExpiresOn: "August 30, 2026",
+ })
+ if err != nil {
+ t.Fatalf("send: %v", err)
+ }
+ t.Log("sent")
+}
diff --git a/internal/email/mailer.go b/internal/email/mailer.go
new file mode 100644
index 000000000..c4214069a
--- /dev/null
+++ b/internal/email/mailer.go
@@ -0,0 +1,166 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package email renders and sends product notification mail.
+package email
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "fmt"
+ htmltemplate "html/template"
+ "strings"
+ texttemplate "text/template"
+
+ "github.com/wneessen/go-mail"
+)
+
+//go:embed templates/*.tmpl
+var templateFS embed.FS
+
+// AccountReadyData fills the account-ready templates. FirstName and
+// ExpiresOn are optional; empty values omit their sections.
+type AccountReadyData struct {
+ Username string
+ PortalURL string
+ ClusterHost string
+ SiteName string
+ FirstName string
+ ExpiresOn string
+}
+
+// Mailer sends product mail. Implementations must be safe for concurrent use.
+type Mailer interface {
+ SendAccountReady(ctx context.Context, to string, data AccountReadyData) error
+}
+
+// Noop drops every message; the default when no relay is configured.
+type Noop struct{}
+
+func (Noop) SendAccountReady(context.Context, string, AccountReadyData) error { return nil }
+
+// Config carries the relay settings plus the deployment identity the
+// templates render.
+type Config struct {
+ Host string
+ Port int
+ Username string
+ Password string
+ From string
+ SiteName string
+ PortalURL string
+ ClusterHost string
+}
+
+// Configured reports whether the relay settings are usable. Values still
+// carrying an unexpanded "${...}" placeholder count as unset.
+func (c Config) Configured() bool {
+ for _, v := range []string{c.Host, c.Username, c.Password, c.SiteName, c.PortalURL, c.ClusterHost} {
+ if v == "" || strings.Contains(v, "${") {
+ return false
+ }
+ }
+ return true
+}
+
+type smtpMailer struct {
+ cfg Config
+ htmlTmpl *htmltemplate.Template
+ textTmpl *texttemplate.Template
+}
+
+// New builds an SMTP-backed mailer, or Noop when the config is incomplete.
+func New(cfg Config) Mailer {
+ if !cfg.Configured() {
+ return Noop{}
+ }
+ if cfg.From == "" || strings.Contains(cfg.From, "${") {
+ cfg.From = cfg.Username
+ }
+ if cfg.Port == 0 {
+ cfg.Port = 587
+ }
+ return &smtpMailer{
+ cfg: cfg,
+ htmlTmpl: htmltemplate.Must(htmltemplate.ParseFS(templateFS, "templates/account-ready.html.tmpl")),
+ textTmpl: texttemplate.Must(texttemplate.ParseFS(templateFS, "templates/account-ready.txt.tmpl")),
+ }
+}
+
+// RenderAccountReady returns the text and HTML parts; exported for tests.
+func RenderAccountReady(data AccountReadyData) (text, html string, err error) {
+ textTmpl, err := texttemplate.ParseFS(templateFS, "templates/account-ready.txt.tmpl")
+ if err != nil {
+ return "", "", err
+ }
+ htmlTmpl, err := htmltemplate.ParseFS(templateFS, "templates/account-ready.html.tmpl")
+ if err != nil {
+ return "", "", err
+ }
+ var tb, hb bytes.Buffer
+ if err := textTmpl.Execute(&tb, data); err != nil {
+ return "", "", err
+ }
+ if err := htmlTmpl.Execute(&hb, data); err != nil {
+ return "", "", err
+ }
+ return tb.String(), hb.String(), nil
+}
+
+func (m *smtpMailer) SendAccountReady(ctx context.Context, to string, data AccountReadyData) error {
+ if data.SiteName == "" {
+ data.SiteName = m.cfg.SiteName
+ }
+ if data.PortalURL == "" {
+ data.PortalURL = m.cfg.PortalURL
+ }
+ if data.ClusterHost == "" {
+ data.ClusterHost = m.cfg.ClusterHost
+ }
+
+ var tb, hb bytes.Buffer
+ if err := m.textTmpl.Execute(&tb, data); err != nil {
+ return fmt.Errorf("render text part: %w", err)
+ }
+ if err := m.htmlTmpl.Execute(&hb, data); err != nil {
+ return fmt.Errorf("render html part: %w", err)
+ }
+
+ msg := mail.NewMsg()
+ if err := msg.From(m.cfg.From); err != nil {
+ return fmt.Errorf("set from: %w", err)
+ }
+ if err := msg.To(to); err != nil {
+ return fmt.Errorf("set recipient: %w", err)
+ }
+ msg.Subject(fmt.Sprintf("Your %s account is ready", data.SiteName))
+ msg.SetBodyString(mail.TypeTextPlain, tb.String())
+ msg.AddAlternativeString(mail.TypeTextHTML, hb.String())
+
+ client, err := mail.NewClient(m.cfg.Host,
+ mail.WithPort(m.cfg.Port),
+ mail.WithSMTPAuth(mail.SMTPAuthPlain),
+ mail.WithUsername(m.cfg.Username),
+ mail.WithPassword(m.cfg.Password),
+ mail.WithTLSPolicy(mail.TLSMandatory),
+ )
+ if err != nil {
+ return fmt.Errorf("build smtp client: %w", err)
+ }
+ return client.DialAndSendWithContext(ctx, msg)
+}
diff --git a/internal/email/mailer_test.go b/internal/email/mailer_test.go
new file mode 100644
index 000000000..26f5d5e88
--- /dev/null
+++ b/internal/email/mailer_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package email
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderAccountReadyFullData(t *testing.T) {
+ text, html, err := RenderAccountReady(AccountReadyData{
+ Username: "trial-jdoe",
+ PortalURL: "https://portal.example.org",
+ ClusterHost: "login.example.org",
+ SiteName: "Example HPC",
+ FirstName: "Jane",
+ ExpiresOn: "August 12, 2026",
+ })
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ for _, want := range []string{"Hi Jane", "trial-jdoe", "ssh trial-jdoe@login.example.org", "August 12, 2026", "https://portal.example.org"} {
+ if !strings.Contains(text, want) {
+ t.Errorf("text part missing %q", want)
+ }
+ if !strings.Contains(html, want) && !strings.Contains(html, strings.ReplaceAll(want, "@", "@")) {
+ t.Errorf("html part missing %q", want)
+ }
+ }
+}
+
+func TestRenderAccountReadyOptionalFieldsOmitted(t *testing.T) {
+ text, html, err := RenderAccountReady(AccountReadyData{
+ Username: "trial-x", PortalURL: "https://p", ClusterHost: "h", SiteName: "S",
+ })
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ if !strings.Contains(text, "Hi,") || strings.Contains(text, "Access ends") {
+ t.Errorf("optional handling wrong in text: %q", text[:80])
+ }
+ if strings.Contains(html, "Access ends") {
+ t.Error("expiry note rendered without ExpiresOn")
+ }
+}
+
+func TestEscapesHTMLInValues(t *testing.T) {
+ _, html, err := RenderAccountReady(AccountReadyData{
+ Username: `x<script>alert(1)</script>`, PortalURL: "https://p", ClusterHost: "h", SiteName: "S",
+ })
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ if strings.Contains(html, "<script>") {
+ t.Error("html part did not escape the username")
+ }
+}
+
+func TestNoopWhenUnconfigured(t *testing.T) {
+ if _, ok := New(Config{Host: "smtp.example.org", Username: "${EMAIL_HOST_USER}"}).(Noop); !ok {
+ t.Error("unexpanded placeholders must yield the noop mailer")
+ }
+ if _, ok := New(Config{}).(Noop); !ok {
+ t.Error("empty config must yield the noop mailer")
+ }
+}
diff --git a/internal/email/templates/account-ready.html.tmpl b/internal/email/templates/account-ready.html.tmpl
new file mode 100644
index 000000000..a2c1a2880
--- /dev/null
+++ b/internal/email/templates/account-ready.html.tmpl
@@ -0,0 +1,127 @@
+<!doctype html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="color-scheme" content="light dark">
+ <meta name="supported-color-schemes" content="light dark">
+ <title>Your {{.SiteName}} account is ready</title>
+ <!--[if mso]>
+ <style type="text/css">table,td,div,p,a{font-family:Arial,Helvetica,sans-serif !important;}</style>
+ <![endif]-->
+</head>
+<body style="margin:0; padding:0; width:100%; background-color:#f4f5f7; -webkit-font-smoothing:antialiased;">
+
+ <!-- preheader: shown in the inbox preview, hidden in the body -->
+ <div style="display:none; max-height:0; overflow:hidden; mso-hide:all; font-size:1px; line-height:1px; color:#f4f5f7;">
+ Your account {{.Username}} is active. Sign in to the portal or connect to the cluster over SSH.
+ </div>
+
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f5f7;">
+ <tr>
+ <td align="center" style="padding:32px 16px;">
+
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:600px; background-color:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
+
+ <!-- brand bar -->
+ <tr>
+ <td style="padding:24px 32px; border-bottom:1px solid #eef0f3;">
+ <span style="font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif; font-size:18px; font-weight:700; letter-spacing:0.02em; color:#2361b8;">{{.SiteName}}</span>
+ </td>
+ </tr>
+
+ <!-- body -->
+ <tr>
+ <td style="padding:32px 32px 8px 32px; font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
+ <h1 style="margin:0 0 12px 0; font-size:22px; line-height:1.3; font-weight:700; color:#111111;">Your account is ready</h1>
+ <p style="margin:0 0 16px 0; font-size:15px; line-height:1.6; color:#374151;">
+ Hi{{if .FirstName}} {{.FirstName}}{{end}}, your request was approved and your {{.SiteName}} account is now active. Your username is:
+ </p>
+
+ <!-- username callout -->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
+ <tr>
+ <td style="padding:12px 16px; background-color:#f4f5f7; border:1px solid #e5e7eb; border-radius:8px; font-family:'SF Mono',Menlo,Consolas,'Liberation Mono',monospace; font-size:16px; font-weight:600; color:#111111;">
+ {{.Username}}
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+
+ <!-- step 1: portal -->
+ <tr>
+ <td style="padding:24px 32px 8px 32px; font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
+ <p style="margin:0 0 6px 0; font-size:12px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:#6b7280;">Step 1 · Open the portal</p>
+ <p style="margin:0 0 16px 0; font-size:15px; line-height:1.6; color:#374151;">
+ Sign in with the same institutional identity you used to request access.
+ </p>
+ <!--[if mso]>
+ <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{.PortalURL}}" style="height:44px;v-text-anchor:middle;width:200px;" arcsize="18%" strokecolor="#2361b8" fillcolor="#2361b8">
+ <w:anchorlock/>
+ <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Sign in to the portal</center>
+ </v:roundrect>
+ <![endif]-->
+ <!--[if !mso]><!-->
+ <a href="{{.PortalURL}}" style="display:inline-block; background-color:#2361b8; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none; padding:12px 24px; border-radius:8px;">Sign in to the portal</a>
+ <!--<![endif]-->
+ </td>
+ </tr>
+
+ <!-- step 2: ssh -->
+ <tr>
+ <td style="padding:24px 32px 8px 32px; font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
+ <p style="margin:0 0 6px 0; font-size:12px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:#6b7280;">Step 2 · Connect to the cluster</p>
+ <p style="margin:0 0 12px 0; font-size:15px; line-height:1.6; color:#374151;">
+ Open a terminal and connect over SSH with the command below.
+ </p>
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
+ <tr>
+ <td style="padding:14px 16px; background-color:#0e274a; border-radius:8px; font-family:'SF Mono',Menlo,Consolas,'Liberation Mono',monospace; font-size:14px; color:#e9f0f8;">
+ <span style="color:#79a4d6;">$</span> ssh {{.Username}}@{{.ClusterHost}}
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ {{if .ExpiresOn}}
+ <!-- trial expiry note (optional) -->
+ <tr>
+ <td style="padding:20px 32px 0 32px; font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
+ <tr>
+ <td style="padding:12px 16px; background-color:#fef3c7; border-radius:8px; font-size:13px; line-height:1.5; color:#92400e;">
+ This is a trial account. Access ends on <strong>{{.ExpiresOn}}</strong>.
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ {{end}}
+ <!-- help -->
+ <tr>
+ <td style="padding:28px 32px 32px 32px; font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
+ <p style="margin:0; font-size:14px; line-height:1.6; color:#6b7280;">
+ Trouble signing in or connecting? Reply to this email and an administrator will help.
+ </p>
+ </td>
+ </tr>
+
+ <!-- footer -->
+ <tr>
+ <td style="padding:20px 32px; background-color:#f9fafb; border-top:1px solid #eef0f3; font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
+ <p style="margin:0; font-size:12px; line-height:1.5; color:#9aa1ae;">
+ You are receiving this because your access request to {{.SiteName}} was approved. This is an automated message.
+ </p>
+ </td>
+ </tr>
+
+ </table>
+
+ </td>
+ </tr>
+ </table>
+
+</body>
+</html>
diff --git a/internal/email/templates/account-ready.txt.tmpl b/internal/email/templates/account-ready.txt.tmpl
new file mode 100644
index 000000000..f03295cbb
--- /dev/null
+++ b/internal/email/templates/account-ready.txt.tmpl
@@ -0,0 +1,23 @@
+Your {{.SiteName}} account is ready
+
+Hi{{if .FirstName}} {{.FirstName}}{{end}}, your request was approved and your
+{{.SiteName}} account is now active.
+
+Username: {{.Username}}
+
+Step 1 - Open the portal
+Sign in with the same institutional identity you used to request access:
+{{.PortalURL}}
+
+Step 2 - Connect to the cluster
+Open a terminal and connect over SSH:
+ssh {{.Username}}@{{.ClusterHost}}
+{{if .ExpiresOn}}
+Note: this is a trial account. Access ends on {{.ExpiresOn}}.
+{{end}}
+Trouble signing in or connecting? Reply to this email and an administrator
+will help.
+
+--
+You are receiving this because your access request to {{.SiteName}} was
+approved. This is an automated message.
diff --git a/pkg/service/access_request.go b/pkg/service/access_request.go
index 207f65ccf..bf50785b3 100644
--- a/pkg/service/access_request.go
+++ b/pkg/service/access_request.go
@@ -22,9 +22,11 @@ import (
"database/sql"
"errors"
"fmt"
+ "log/slog"
"strings"
"time"
+ "github.com/apache/airavata-custos/internal/email"
"github.com/apache/airavata-custos/internal/store"
"github.com/apache/airavata-custos/pkg/models"
)
@@ -172,7 +174,7 @@ func (s *Service) ApproveAccessRequest(ctx context.Context, id, approverID strin
exp = expiresAt.UTC()
}
- user, err := s.provisionAccessRequest(ctx, req, exp)
+ user, username, err := s.provisionAccessRequest(ctx, req, exp)
if err != nil {
s.appendAccessRequestEvent(ctx, req.ID, models.AccessRequestEventFailed, fmt.Sprintf("Approval failed: %v", err))
return nil, err
@@ -196,29 +198,49 @@ func (s *Service) ApproveAccessRequest(ctx context.Context, id, approverID strin
}); err != nil {
return nil, fmt.Errorf("approve access request: %w", err)
}
+
+ // Best effort and off the request path: the approval stands even when
+ // the notification cannot be delivered.
+ go s.sendAccountReadyEmail(req, username, exp)
+
return req, nil
}
+func (s *Service) sendAccountReadyEmail(req *models.AccessRequest, username string, expiresAt time.Time) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ first, _ := splitName(req.Name)
+ err := s.mailer.SendAccountReady(ctx, req.Email, email.AccountReadyData{
+ Username: username,
+ FirstName: first,
+ ExpiresOn: expiresAt.Format("January 2, 2006"),
+ })
+ if err != nil {
+ slog.Error("account-ready email failed", "request_id", req.ID, "error", err)
+ s.appendAccessRequestEvent(ctx, req.ID, models.AccessRequestEventFailed, fmt.Sprintf("Account-ready email failed: %v", err))
+ }
+}
+
// provisionAccessRequest creates the user, identity binding, cluster user,
// and allocation membership for an approval. Every step tolerates leftovers
// from an earlier partial run so a failed approval can be retried.
-func (s *Service) provisionAccessRequest(ctx context.Context, req *models.AccessRequest, expiresAt time.Time) (*models.User, error) {
+func (s *Service) provisionAccessRequest(ctx context.Context, req *models.AccessRequest, expiresAt time.Time) (*models.User, string, error) {
ev, err := s.accessEvents.FindByCode(ctx, req.EventCode)
if err != nil {
- return nil, fmt.Errorf("lookup access event: %w", err)
+ return nil, "", fmt.Errorf("lookup access event: %w", err)
}
if ev == nil {
- return nil, fmt.Errorf("%w: access event %q", ErrNotFound, req.EventCode)
+ return nil, "", fmt.Errorf("%w: access event %q", ErrNotFound, req.EventCode)
}
alloc, err := s.allocs.FindByID(ctx, ev.ComputeAllocationID)
if err != nil {
- return nil, fmt.Errorf("lookup compute allocation: %w", err)
+ return nil, "", fmt.Errorf("lookup compute allocation: %w", err)
}
if alloc == nil {
- return nil, fmt.Errorf("%w: compute allocation %q not found", ErrInvalidInput, ev.ComputeAllocationID)
+ return nil, "", fmt.Errorf("%w: compute allocation %q not found", ErrInvalidInput, ev.ComputeAllocationID)
}
if alloc.Status != models.ACTIVE {
- return nil, fmt.Errorf("%w: compute allocation %q is %s, must be ACTIVE", ErrInvalidInput, alloc.ID, alloc.Status)
+ return nil, "", fmt.Errorf("%w: compute allocation %q is %s, must be ACTIVE", ErrInvalidInput, alloc.ID, alloc.Status)
}
first, last := splitName(req.Name)
@@ -236,7 +258,7 @@ func (s *Service) provisionAccessRequest(ctx context.Context, req *models.Access
}
}
if err != nil {
- return nil, fmt.Errorf("create user: %w", err)
+ return nil, "", fmt.Errorf("create user: %w", err)
}
if _, err := s.CreateUserIdentity(ctx, &models.UserIdentity{
@@ -246,17 +268,22 @@ func (s *Service) provisionAccessRequest(ctx context.Context, req *models.Access
OIDCSub: req.OIDCSub,
Email: req.Email,
}); err != nil && !errors.Is(err, ErrAlreadyExists) {
- return nil, fmt.Errorf("create user identity: %w", err)
+ return nil, "", fmt.Errorf("create user identity: %w", err)
}
// Check the pair first: the allocator treats a pair duplicate as a
// username collision and would burn its retry budget on the same pair.
+ username := ""
if existing, err := s.clusterUsers.FindByPair(ctx, alloc.ComputeClusterID, user.ID); err != nil {
- return nil, fmt.Errorf("lookup compute cluster user: %w", err)
- } else if existing == nil {
- if _, err := s.AllocateComputeClusterUser(ctx, user, alloc.ComputeClusterID); err != nil {
- return nil, fmt.Errorf("allocate compute cluster user: %w", err)
+ return nil, "", fmt.Errorf("lookup compute cluster user: %w", err)
+ } else if existing != nil {
+ username = existing.LocalUsername
+ } else {
+ ccu, err := s.AllocateComputeClusterUser(ctx, user, alloc.ComputeClusterID)
+ if err != nil {
+ return nil, "", fmt.Errorf("allocate compute cluster user: %w", err)
}
+ username = ccu.LocalUsername
}
if _, err := s.CreateComputeAllocationMembership(ctx, &models.ComputeAllocationMembership{
@@ -266,9 +293,9 @@ func (s *Service) provisionAccessRequest(ctx context.Context, req *models.Access
EndTime: expiresAt,
MembershipStatus: models.ACTIVE,
}); err != nil && !errors.Is(err, ErrAlreadyExists) {
- return nil, fmt.Errorf("create compute allocation membership: %w", err)
+ return nil, "", fmt.Errorf("create compute allocation membership: %w", err)
}
- return user, nil
+ return user, username, nil
}
// DenyAccessRequest transitions a PENDING request to DENIED, recording the
diff --git a/pkg/service/service.go b/pkg/service/service.go
index af7d6d038..dd2e71d37 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -26,6 +26,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/apache/airavata-custos/internal/db"
+ "github.com/apache/airavata-custos/internal/email"
"github.com/apache/airavata-custos/internal/store"
"github.com/apache/airavata-custos/pkg/events"
)
@@ -62,6 +63,14 @@ type Service struct {
userRoles store.UserRoleStore
auditTraces store.AuditTraceStore
identityCache *identityCache
+ mailer email.Mailer
+}
+
+// SetMailer configures notification mail; unset means messages are dropped.
+func (s *Service) SetMailer(m email.Mailer) {
+ if m != nil {
+ s.mailer = m
+ }
}
// New constructs a Service backed by the supplied database handle.
@@ -96,6 +105,7 @@ func New(database *sqlx.DB, eventBus *events.Bus) *Service {
userRoles: store.NewUserRoleStore(database),
auditTraces: store.NewAuditTraceStore(database),
identityCache: newIdentityCache(),
+ mailer: email.Noop{},
}
}
@@ -156,6 +166,7 @@ func NewWithStores(
userRoles: userRoles,
auditTraces: store.NewAuditTraceStore(database),
identityCache: newIdentityCache(),
+ mailer: email.Noop{},
}
}