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 &middot; 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 &middot; 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>&nbsp;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{},
 	}
 }