docs/superpowers/plans/2026-04-12-email-service-integration.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add email (SMTP) configuration as a workspace setting, with a mail sender plugin and test-email endpoint.
Architecture: Email config is stored as a new EMAIL setting in the existing setting table (no new DB table). Global SMTP config is injected from EMAIL_CONFIG env var during workspace creation via getAdditionalWorkspaceSettings(). A backend/plugin/mail/ plugin provides the Sender interface for sending emails.
Tech Stack: Go stdlib net/smtp + crypto/tls, protobuf, Connect-RPC.
Spec: docs/superpowers/specs/2026-04-12-email-service-integration-design.md
Files:
Modify: proto/store/store/setting.proto:13-23 (SettingName enum) and append after line 342
Step 1: Add EMAIL to SettingName enum
In proto/store/store/setting.proto, add EMAIL = 9 after ENVIRONMENT = 8:
enum SettingName {
SETTING_NAME_UNSPECIFIED = 0;
SYSTEM = 1;
WORKSPACE_PROFILE = 2;
WORKSPACE_APPROVAL = 3;
APP_IM = 4;
AI = 5;
DATA_CLASSIFICATION = 6;
SEMANTIC_TYPES = 7;
ENVIRONMENT = 8;
EMAIL = 9;
}
Append after the EnvironmentSetting message (after line 342):
message EmailSetting {
string from = 1;
string from_name = 2;
Type type = 3;
enum Type {
TYPE_UNSPECIFIED = 0;
SMTP = 1;
}
oneof config {
SMTPConfig smtp = 4;
}
message SMTPConfig {
string host = 1;
int32 port = 2;
string username = 3;
string password = 4;
Encryption encryption = 5;
Authentication authentication = 6;
enum Encryption {
ENCRYPTION_UNSPECIFIED = 0;
NONE = 1;
STARTTLS = 2;
SSL_TLS = 3;
}
enum Authentication {
AUTHENTICATION_UNSPECIFIED = 0;
NONE = 1;
PLAIN = 2;
LOGIN = 3;
CRAM_MD5 = 4;
}
}
}
Run: cd proto && buf format -w . && buf lint
Expected: No errors.
git add proto/store/store/setting.proto
git commit -m "proto(store): add EMAIL setting name and EmailSetting message"
Files:
Modify: proto/v1/v1/setting_service.proto:93-102 (SettingName enum), :114-124 (SettingValue oneof), :19-49 (service RPCs), and append messages at EOF
Step 1: Add EMAIL to v1 SettingName enum
In proto/v1/v1/setting_service.proto, add EMAIL = 8 after ENVIRONMENT = 7 (line 101):
enum SettingName {
SETTING_NAME_UNSPECIFIED = 0;
WORKSPACE_PROFILE = 1;
WORKSPACE_APPROVAL = 2;
APP_IM = 3;
AI = 4;
DATA_CLASSIFICATION = 5;
SEMANTIC_TYPES = 6;
ENVIRONMENT = 7;
EMAIL = 8;
}
Add EmailSetting email = 8; to the SettingValue message (after line 122):
message SettingValue {
oneof value {
AppIMSetting app_im = 1;
WorkspaceProfileSetting workspace_profile = 2;
WorkspaceApprovalSetting workspace_approval = 3;
DataClassificationSetting data_classification = 4;
SemanticTypeSetting semantic_type = 5;
AISetting ai = 6;
EnvironmentSetting environment = 7;
EmailSetting email = 8;
}
}
Add after the UpdateSetting RPC (line 48), before the closing brace:
// Sends a test email using the provided config (without persisting).
// Permissions required: bb.settings.set
rpc TestEmailSetting(TestEmailSettingRequest) returns (TestEmailSettingResponse) {
option (google.api.http) = {
post: "/v1/{parent=workspaces/*}/settings/EMAIL:test"
body: "*"
};
option (bytebase.v1.permission) = "bb.settings.set";
option (bytebase.v1.auth_method) = IAM;
}
Append at the end of setting_service.proto (after line 463):
message EmailSetting {
string from = 1;
string from_name = 2;
Type type = 3;
enum Type {
TYPE_UNSPECIFIED = 0;
SMTP = 1;
}
oneof config {
SMTPConfig smtp = 4;
}
message SMTPConfig {
string host = 1;
int32 port = 2;
string username = 3;
// INPUT_ONLY — never returned in GET responses.
string password = 4 [(google.api.field_behavior) = INPUT_ONLY];
Encryption encryption = 5;
Authentication authentication = 6;
enum Encryption {
ENCRYPTION_UNSPECIFIED = 0;
NONE = 1;
STARTTLS = 2;
SSL_TLS = 3;
}
enum Authentication {
AUTHENTICATION_UNSPECIFIED = 0;
NONE = 1;
PLAIN = 2;
LOGIN = 3;
CRAM_MD5 = 4;
}
}
}
message TestEmailSettingRequest {
// Parent workspace. Format: workspaces/{workspace}
string parent = 1 [(google.api.field_behavior) = REQUIRED];
// The email config to test. Not persisted.
EmailSetting email_setting = 2 [(google.api.field_behavior) = REQUIRED];
// The recipient to send the test email to.
string to = 3 [(google.api.field_behavior) = REQUIRED];
}
message TestEmailSettingResponse {
bool success = 1;
// Human-readable error if success=false.
string error = 2;
}
Run:
cd proto && buf format -w . && buf lint && buf generate
Expected: No errors.
git add proto/ backend/generated-go/ frontend/src/types/proto-es/
git commit -m "proto(v1): add EMAIL setting, EmailSetting message, and TestEmailSetting RPC"
Files:
Modify: backend/store/setting.go:24-43 (getSettingMessage switch)
Step 1: Add EMAIL case
In backend/store/setting.go, add a case for EMAIL in the getSettingMessage function (after the SettingName_ENVIRONMENT case, around line 41):
case storepb.SettingName_ENVIRONMENT:
return &storepb.EnvironmentSetting{}, nil
case storepb.SettingName_EMAIL:
return &storepb.EmailSetting{}, nil
Run: go build ./backend/store/...
Expected: Success.
git add backend/store/setting.go
git commit -m "feat(store): register EMAIL setting name in getSettingMessage"
Files:
Modify: backend/api/v1/setting_service_converter.go:15-124 (convertToSettingMessage), :140-165 (convertStoreSettingNameToV1), :167-190 (convertV1SettingNameToStore), and append converter functions at EOF
Step 1: Add EMAIL case to convertToSettingMessage
In setting_service_converter.go, add the EMAIL case before the default case (before line 121):
case storepb.SettingName_EMAIL:
storeValue, ok := setting.Value.(*storepb.EmailSetting)
if !ok {
return nil, connect.NewError(connect.CodeInternal, errors.Errorf("invalid setting value type for %s", setting.Name))
}
return &v1pb.Setting{
Name: settingName,
Value: &v1pb.SettingValue{
Value: &v1pb.SettingValue_Email{
Email: convertToEmailSetting(storeValue),
},
},
}, nil
In the convertStoreSettingNameToV1 function (around line 158), add before case storepb.SettingName_SYSTEM:
case storepb.SettingName_EMAIL:
return v1pb.Setting_EMAIL
In the convertV1SettingNameToStore function (around line 186), add before default:
case v1pb.Setting_EMAIL:
return storepb.SettingName_EMAIL
Append at the end of setting_service_converter.go:
func convertEmailSetting(v1Setting *v1pb.EmailSetting) *storepb.EmailSetting {
if v1Setting == nil {
return nil
}
storeSetting := &storepb.EmailSetting{
From: v1Setting.From,
FromName: v1Setting.FromName,
Type: storepb.EmailSetting_Type(v1Setting.Type),
}
if v1Smtp := v1Setting.GetSmtp(); v1Smtp != nil {
storeSetting.Config = &storepb.EmailSetting_Smtp{
Smtp: &storepb.EmailSetting_SMTPConfig{
Host: v1Smtp.Host,
Port: v1Smtp.Port,
Username: v1Smtp.Username,
Password: v1Smtp.Password,
Encryption: storepb.EmailSetting_SMTPConfig_Encryption(v1Smtp.Encryption),
Authentication: storepb.EmailSetting_SMTPConfig_Authentication(v1Smtp.Authentication),
},
}
}
return storeSetting
}
func convertToEmailSetting(storeSetting *storepb.EmailSetting) *v1pb.EmailSetting {
if storeSetting == nil {
return nil
}
v1Setting := &v1pb.EmailSetting{
From: storeSetting.From,
FromName: storeSetting.FromName,
Type: v1pb.EmailSetting_Type(storeSetting.Type),
}
if storeSmtp := storeSetting.GetSmtp(); storeSmtp != nil {
v1Setting.Config = &v1pb.EmailSetting_Smtp{
Smtp: &v1pb.EmailSetting_SMTPConfig{
Host: storeSmtp.Host,
Port: storeSmtp.Port,
Username: storeSmtp.Username,
Password: "", // INPUT_ONLY: never return password
Encryption: v1pb.EmailSetting_SMTPConfig_Encryption(storeSmtp.Encryption),
Authentication: v1pb.EmailSetting_SMTPConfig_Authentication(storeSmtp.Authentication),
},
}
}
return v1Setting
}
Run: go build ./backend/api/v1/...
Expected: Success.
git add backend/api/v1/setting_service_converter.go
git commit -m "feat(api): add EMAIL setting converter functions"
Files:
Modify: backend/api/v1/setting_service.go:533-612 (UpdateSetting switch, add case before default)
Step 1: Add EMAIL case to UpdateSetting validation
In setting_service.go, add a case storepb.SettingName_EMAIL: before the default: case (before line 611):
case storepb.SettingName_EMAIL:
if request.Msg.UpdateMask == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("update mask is required"))
}
payload := convertEmailSetting(request.Msg.Setting.Value.GetEmail())
if payload == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("email setting is required"))
}
oldEmailSetting := &storepb.EmailSetting{}
if existing, err := s.store.GetSetting(ctx, workspaceID, storepb.SettingName_EMAIL); err != nil {
return nil, connect.NewError(connect.CodeInternal, errors.Errorf("failed to get email setting: %v", err))
} else if existing != nil {
oldEmailSetting = proto.CloneOf(existing.Value.(*storepb.EmailSetting))
}
for _, path := range request.Msg.UpdateMask.Paths {
switch path {
case "value.email.from":
oldEmailSetting.From = payload.From
case "value.email.from_name":
oldEmailSetting.FromName = payload.FromName
case "value.email.type":
oldEmailSetting.Type = payload.Type
case "value.email.smtp":
newSmtp := payload.GetSmtp()
if newSmtp == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("smtp config is required when type is SMTP"))
}
// Preserve existing password if new password is empty.
if newSmtp.Password == "" {
if oldSmtp := oldEmailSetting.GetSmtp(); oldSmtp != nil {
newSmtp.Password = oldSmtp.Password
}
}
oldEmailSetting.Config = &storepb.EmailSetting_Smtp{Smtp: newSmtp}
default:
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("invalid update mask path %v", path))
}
}
// Validate the final state.
if oldEmailSetting.Type == storepb.EmailSetting_SMTP {
smtp := oldEmailSetting.GetSmtp()
if smtp == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("smtp config is required when type is SMTP"))
}
if smtp.Host == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("smtp host is required"))
}
if smtp.Port <= 0 {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("smtp port must be positive"))
}
}
if oldEmailSetting.From == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("from address is required"))
}
storeSettingValue = oldEmailSetting
Run:
go build ./backend/api/v1/...
golangci-lint run --allow-parallel-runners ./backend/api/v1/...
Expected: Success, 0 lint issues.
git add backend/api/v1/setting_service.go
git commit -m "feat(api): add EMAIL validation in UpdateSetting"
Files:
Create: backend/plugin/mail/mail.go
Create: backend/plugin/mail/smtp.go
Create: backend/plugin/mail/smtp_test.go
Step 1: Create mail plugin interface
Create backend/plugin/mail/mail.go:
package mail
import (
"context"
"github.com/pkg/errors"
storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)
// Sender sends emails.
type Sender interface {
Send(ctx context.Context, req *SendRequest) error
}
// SendRequest is the request to send an email.
type SendRequest struct {
To []string
Subject string
TextBody string
HTMLBody string
}
// NewSender creates a Sender from the stored email configuration.
func NewSender(cfg *storepb.EmailSetting) (Sender, error) {
if cfg == nil {
return nil, errors.Errorf("email setting is nil")
}
switch cfg.Type {
case storepb.EmailSetting_SMTP:
smtp := cfg.GetSmtp()
if smtp == nil {
return nil, errors.Errorf("smtp config is nil")
}
return newSMTPSender(cfg.From, cfg.FromName, smtp), nil
default:
return nil, errors.Errorf("unsupported email type: %v", cfg.Type)
}
}
Create backend/plugin/mail/smtp.go:
package mail
import (
"context"
"crypto/tls"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
"github.com/pkg/errors"
storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)
const (
connectTimeout = 10 * time.Second
sendTimeout = 30 * time.Second
)
type smtpSender struct {
from string
fromName string
config *storepb.EmailSetting_SMTPConfig
}
func newSMTPSender(from, fromName string, config *storepb.EmailSetting_SMTPConfig) *smtpSender {
return &smtpSender{from: from, fromName: fromName, config: config}
}
func (s *smtpSender) Send(ctx context.Context, req *SendRequest) error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
client, err := s.dial(addr)
if err != nil {
return errors.Wrapf(err, "failed to connect to SMTP server %s", addr)
}
defer client.Close()
if s.config.Encryption == storepb.EmailSetting_SMTPConfig_STARTTLS {
tlsConfig := &tls.Config{ServerName: s.config.Host}
if err := client.StartTLS(tlsConfig); err != nil {
return errors.Wrap(err, "STARTTLS failed")
}
}
if auth := s.auth(); auth != nil {
if err := client.Auth(auth); err != nil {
return errors.Wrap(err, "SMTP authentication failed")
}
}
if err := client.Mail(s.from); err != nil {
return errors.Wrap(err, "MAIL FROM failed")
}
for _, to := range req.To {
if err := client.Rcpt(to); err != nil {
return errors.Wrapf(err, "RCPT TO %s failed", to)
}
}
w, err := client.Data()
if err != nil {
return errors.Wrap(err, "DATA command failed")
}
msg := s.buildMessage(req)
if _, err := w.Write([]byte(msg)); err != nil {
return errors.Wrap(err, "failed to write message")
}
if err := w.Close(); err != nil {
return errors.Wrap(err, "failed to close data writer")
}
return client.Quit()
}
func (s *smtpSender) dial(addr string) (*smtp.Client, error) {
switch s.config.Encryption {
case storepb.EmailSetting_SMTPConfig_SSL_TLS:
tlsConfig := &tls.Config{ServerName: s.config.Host}
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: connectTimeout}, "tcp", addr, tlsConfig)
if err != nil {
return nil, err
}
return smtp.NewClient(conn, s.config.Host)
default:
conn, err := net.DialTimeout("tcp", addr, connectTimeout)
if err != nil {
return nil, err
}
return smtp.NewClient(conn, s.config.Host)
}
}
func (s *smtpSender) auth() smtp.Auth {
if s.config.Authentication == storepb.EmailSetting_SMTPConfig_NONE {
return nil
}
if s.config.Username == "" && s.config.Password == "" {
return nil
}
switch s.config.Authentication {
case storepb.EmailSetting_SMTPConfig_CRAM_MD5:
return smtp.CRAMMD5Auth(s.config.Username, s.config.Password)
default:
// PLAIN, LOGIN, and UNSPECIFIED all use PlainAuth.
return smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
}
}
func (s *smtpSender) buildMessage(req *SendRequest) string {
var b strings.Builder
fromHeader := s.from
if s.fromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", s.fromName), s.from)
}
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(req.To, ", "))
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", req.Subject))
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
if req.HTMLBody != "" {
boundary := "bytebase-email-boundary"
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%s\r\n\r\n", boundary)
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n\r\n")
fmt.Fprintf(&b, "%s\r\n", req.TextBody)
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/html; charset=utf-8\r\n\r\n")
fmt.Fprintf(&b, "%s\r\n", req.HTMLBody)
fmt.Fprintf(&b, "--%s--\r\n", boundary)
} else {
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n\r\n")
fmt.Fprintf(&b, "%s\r\n", req.TextBody)
}
return b.String()
}
Create backend/plugin/mail/smtp_test.go:
package mail
import (
"context"
"net"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)
func TestBuildMessage_PlainText(t *testing.T) {
s := &smtpSender{from: "[email protected]", fromName: "Bytebase"}
msg := s.buildMessage(&SendRequest{
To: []string{"[email protected]"},
Subject: "Test Subject",
TextBody: "Hello, World!",
})
assert.Contains(t, msg, "From: =?utf-8?q?Bytebase?= <[email protected]>")
assert.Contains(t, msg, "To: [email protected]")
assert.Contains(t, msg, "Subject: =?utf-8?q?Test_Subject?=")
assert.Contains(t, msg, "Content-Type: text/plain; charset=utf-8")
assert.Contains(t, msg, "Hello, World!")
assert.NotContains(t, msg, "multipart")
}
func TestBuildMessage_HTML(t *testing.T) {
s := &smtpSender{from: "[email protected]", fromName: ""}
msg := s.buildMessage(&SendRequest{
To: []string{"[email protected]", "[email protected]"},
Subject: "HTML Test",
TextBody: "plain text",
HTMLBody: "<p>html body</p>",
})
assert.Contains(t, msg, "From: [email protected]")
assert.Contains(t, msg, "To: [email protected], [email protected]")
assert.Contains(t, msg, "multipart/alternative")
assert.Contains(t, msg, "plain text")
assert.Contains(t, msg, "<p>html body</p>")
}
func TestNewSender_SMTP(t *testing.T) {
sender, err := NewSender(&storepb.EmailSetting{
From: "[email protected]",
Type: storepb.EmailSetting_SMTP,
Config: &storepb.EmailSetting_Smtp{
Smtp: &storepb.EmailSetting_SMTPConfig{
Host: "localhost",
Port: 25,
Encryption: storepb.EmailSetting_SMTPConfig_NONE,
},
},
})
require.NoError(t, err)
assert.NotNil(t, sender)
}
func TestNewSender_NilConfig(t *testing.T) {
_, err := NewSender(nil)
assert.Error(t, err)
}
func TestNewSender_UnsupportedType(t *testing.T) {
_, err := NewSender(&storepb.EmailSetting{
From: "[email protected]",
Type: storepb.EmailSetting_TYPE_UNSPECIFIED,
})
assert.Error(t, err)
}
func TestSMTPSend_ConnectionRefused(t *testing.T) {
// Find a port that's guaranteed not listening.
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
sender, err := NewSender(&storepb.EmailSetting{
From: "[email protected]",
Type: storepb.EmailSetting_SMTP,
Config: &storepb.EmailSetting_Smtp{
Smtp: &storepb.EmailSetting_SMTPConfig{
Host: "127.0.0.1",
Port: int32(port),
Encryption: storepb.EmailSetting_SMTPConfig_NONE,
},
},
})
require.NoError(t, err)
err = sender.Send(context.Background(), &SendRequest{
To: []string{"[email protected]"},
Subject: "Test",
TextBody: "body",
})
assert.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "connect") || strings.Contains(err.Error(), "refused"))
}
func TestAuth_None(t *testing.T) {
s := &smtpSender{config: &storepb.EmailSetting_SMTPConfig{
Authentication: storepb.EmailSetting_SMTPConfig_NONE,
}}
assert.Nil(t, s.auth())
}
func TestAuth_Plain(t *testing.T) {
s := &smtpSender{config: &storepb.EmailSetting_SMTPConfig{
Authentication: storepb.EmailSetting_SMTPConfig_PLAIN,
Username: "user",
Password: "pass",
Host: "smtp.example.com",
}}
assert.NotNil(t, s.auth())
}
Run: go test -v -count=1 github.com/bytebase/bytebase/backend/plugin/mail
Expected: All tests pass.
git add backend/plugin/mail/
git commit -m "feat(plugin): add mail sender plugin with SMTP implementation"
Files:
Modify: backend/api/v1/setting_service.go (add TestEmailSetting method + import mail plugin)
Step 1: Add import for mail plugin
In setting_service.go, add to the imports (around line 26):
"github.com/bytebase/bytebase/backend/plugin/mail"
Add after the UpdateSetting method (around line 680):
// TestEmailSetting sends a test email using the provided config.
func (s *SettingService) TestEmailSetting(ctx context.Context, req *connect.Request[v1pb.TestEmailSettingRequest]) (*connect.Response[v1pb.TestEmailSettingResponse], error) {
emailSetting := convertEmailSetting(req.Msg.EmailSetting)
if emailSetting == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("email_setting is required"))
}
// Substitute stored password if not provided.
if smtp := emailSetting.GetSmtp(); smtp != nil && smtp.Password == "" {
workspaceID := common.GetWorkspaceIDFromContext(ctx)
if existing, err := s.store.GetSetting(ctx, workspaceID, storepb.SettingName_EMAIL); err == nil && existing != nil {
if oldEmail, ok := existing.Value.(*storepb.EmailSetting); ok {
if oldSmtp := oldEmail.GetSmtp(); oldSmtp != nil {
smtp.Password = oldSmtp.Password
}
}
}
}
sender, err := mail.NewSender(emailSetting)
if err != nil {
return connect.NewResponse(&v1pb.TestEmailSettingResponse{
Success: false,
Error: err.Error(),
}), nil
}
err = sender.Send(ctx, &mail.SendRequest{
To: []string{req.Msg.To},
Subject: "Bytebase email config test",
TextBody: "This is a test email from Bytebase to verify your email configuration.",
})
if err != nil {
return connect.NewResponse(&v1pb.TestEmailSettingResponse{
Success: false,
Error: err.Error(),
}), nil
}
return connect.NewResponse(&v1pb.TestEmailSettingResponse{
Success: true,
}), nil
}
Run:
go build ./backend/api/v1/...
golangci-lint run --allow-parallel-runners ./backend/api/v1/...
Expected: Success, 0 lint issues.
git add backend/api/v1/setting_service.go
git commit -m "feat(api): implement TestEmailSetting RPC"
Files:
Modify: backend/api/v1/auth_service.go:1393-1410 (getAdditionalWorkspaceSettings)
Step 1: Add os and protojson imports
Ensure these imports are present in auth_service.go (they may already be):
"os"
and
"google.golang.org/protobuf/encoding/protojson"
In getAdditionalWorkspaceSettings(), add after the Gemini block (after line 1408):
if raw := os.Getenv("EMAIL_CONFIG"); raw != "" {
emailSetting := &storepb.EmailSetting{}
if err := protojson.Unmarshal([]byte(raw), emailSetting); err != nil {
slog.Error("failed to parse EMAIL_CONFIG env var", log.BBError(err))
} else {
settings = append(settings, store.AdditionalSetting{
Name: storepb.SettingName_EMAIL,
Payload: emailSetting,
})
}
}
Run:
go build ./backend/api/v1/...
golangci-lint run --allow-parallel-runners ./backend/api/v1/...
Expected: Success, 0 lint issues.
git add backend/api/v1/auth_service.go
git commit -m "feat(auth): inject EMAIL_CONFIG env var into workspace settings on creation"
Files: None new — validation pass.
Run: go build -ldflags "-w -s" -p=16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go
Expected: Success.
Run: golangci-lint run --allow-parallel-runners
Expected: 0 issues (run repeatedly until clean).
Run: pnpm --dir frontend fix
Expected: No errors.
Run: pnpm --dir frontend type-check
Expected: Success.
Run: go test -v -count=1 github.com/bytebase/bytebase/backend/plugin/mail
Expected: All tests pass.
If pnpm fix or golangci-lint --fix modified files:
git add -A
git commit -m "chore: fix lint and formatting"