4
0
Эх сурвалжийг харах

Merge pull request #50 from jgknight/additional-admin-functions

Additional admin functions
Mike 2 жил өмнө
parent
commit
f5edf66cf3

+ 7 - 7
cmd/server/main.go

@@ -52,7 +52,7 @@ func main() {
 	}()
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BOS")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore)
 		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
 		chatNavService := foodgroup.NewChatNavService(logger, feedbagStore, state.NewChatRoom)
@@ -85,7 +85,7 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		chatService := foodgroup.NewChatService(chatSessionManager)
 		oServiceService := foodgroup.NewOServiceServiceForChat(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore, feedbagStore, chatSessionManager)
 
@@ -104,7 +104,7 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT_NAV")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		chatNavService := foodgroup.NewChatNavService(logger, feedbagStore, state.NewChatRoom)
 		oServiceService := foodgroup.NewOServiceServiceForChatNav(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore)
 
@@ -124,7 +124,7 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "ALERT")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		oServiceService := foodgroup.NewOServiceServiceForAlert(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore)
 
 		oscar.BOSServer{
@@ -144,7 +144,7 @@ func main() {
 		logger = logger.With("svc", "ADMIN")
 		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
 		adminService := foodgroup.NewAdminService(sessionManager, feedbagStore, buddyService, sessionManager)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		oServiceService := foodgroup.NewOServiceServiceForAdmin(cfg, logger, buddyService)
 
 		oscar.AdminServer{
@@ -164,7 +164,7 @@ func main() {
 		logger = logger.With("svc", "BART")
 		sessionManager := state.NewInMemorySessionManager(logger)
 		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		oServiceService := foodgroup.NewOServiceServiceForBART(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore)
 
 		oscar.BOSServer{
@@ -182,7 +182,7 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
-		authHandler := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, nil, nil, chatSessionManager)
+		authHandler := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, nil, nil, chatSessionManager, feedbagStore)
 
 		oscar.AuthServer{
 			AuthService: authHandler,

+ 141 - 48
foodgroup/admin.go

@@ -2,6 +2,8 @@ package foodgroup
 
 import (
 	"context"
+	"errors"
+	"net/mail"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -32,25 +34,50 @@ type AdminService struct {
 	messageRelayer         MessageRelayer
 }
 
-// ConfirmRequest returns the ScreenName account status. It returns SNAC
-// wire.AdminConfirmReply. The values in the return SNAC are
-// flag, URL, length
-func (s AdminService) ConfirmRequest(_ context.Context, frame wire.SNACFrame) (wire.SNACMessage, error) {
-	return wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminAcctConfirmReply,
-			RequestID: frame.RequestID,
-		},
-		Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
-			Status: wire.AdminAcctConfirmStatusEmailSent, // todo: get from session/db
-		},
-	}, nil
+// ConfirmRequest will mark the user account as confirmed if the user has an email address set
+func (s AdminService) ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error) {
+	// getAdminInfoReply returns an AdminAcctConfirmReply SNAC
+	var getAdminConfirmReply = func(status uint16) wire.SNACMessage {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Admin,
+				SubGroup:  wire.AdminAcctConfirmReply,
+				RequestID: frame.RequestID,
+			},
+			Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
+				Status: status,
+			},
+		}
+	}
+
+	_, err := s.accountManager.EmailAddressByName(sess.IdentScreenName())
+	if errors.Is(err, state.ErrNoEmailAddress) {
+		return getAdminConfirmReply(wire.AdminAcctConfirmStatusServerError), nil
+	} else if err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	accountConfirmed, err := s.accountManager.ConfirmStatusByName(sess.IdentScreenName())
+	if err != nil {
+		return wire.SNACMessage{}, err
+	}
+	if accountConfirmed {
+		return getAdminConfirmReply(wire.AdminAcctConfirmStatusAlreadyConfirmed), nil
+	}
+	if err := s.accountManager.UpdateConfirmStatus(true, sess.IdentScreenName()); err != nil {
+		return wire.SNACMessage{}, err
+	}
+	sess.ClearUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+		return wire.SNACMessage{}, err
+	}
+	return getAdminConfirmReply(wire.AdminAcctConfirmStatusEmailSent), nil
 }
 
 // InfoQuery returns the requested information about the account
 func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error) {
-	var getAdminInfoReply = func(tag uint16, val any) wire.SNACMessage {
+	// getAdminInfoReply returns an AdminInfoReply SNAC
+	var getAdminInfoReply = func(tlvList wire.TLVList) wire.SNACMessage {
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.Admin,
@@ -60,28 +87,38 @@ func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wi
 			Body: wire.SNAC_0x07_0x03_AdminInfoReply{
 				Permissions: wire.AdminInfoPermissionsReadWrite, // todo: what does this actually control?
 				TLVBlock: wire.TLVBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLV(tag, val),
-					},
+					TLVList: tlvList,
 				},
 			},
 		}
 	}
 
-	// wire.AdminTLVRegistrationStatus is used in the AIM Preferences > Privacy panel to control
-	// Allow users who know my e-mail address to find...
-	//	o Nothing about me - wire.AdminInfoRegStatusNoDisclosure
-	//	o Only that I have an account - wire.AdminInfoRegStatusLimitDisclosure
-	//	o My screen name - wire.AdminInfoRegStatusFullDisclosure
-	if _, hasRegStatus := body.TLVRestBlock.Slice(wire.AdminTLVRegistrationStatus); hasRegStatus {
-		return getAdminInfoReply(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusFullDisclosure), nil // todo: get from session/db
+	tlvList := wire.TLVList{}
 
-	} else if _, hasEmail := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmail {
-		return getAdminInfoReply(wire.AdminTLVEmailAddress, sess.IdentScreenName().String()+"@aol.com"), nil // todo: get from session/db
+	if _, hasRegStatus := body.TLVRestBlock.Slice(wire.AdminTLVRegistrationStatus); hasRegStatus {
+		regStatus, err := s.accountManager.RegStatusByName(sess.IdentScreenName())
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		tlvList.Append(wire.NewTLV(wire.AdminTLVRegistrationStatus, regStatus))
+		return getAdminInfoReply(tlvList), nil
+	}
 
-	} else if _, hasNickName := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasNickName {
-		return getAdminInfoReply(wire.AdminTLVScreenNameFormatted, sess.DisplayScreenName().String()), nil
+	if _, hasEmail := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmail {
+		e, err := s.accountManager.EmailAddressByName(sess.IdentScreenName())
+		if errors.Is(err, state.ErrNoEmailAddress) {
+			tlvList.Append(wire.NewTLV(wire.AdminTLVEmailAddress, ""))
+		} else if err != nil {
+			return wire.SNACMessage{}, err
+		} else {
+			tlvList.Append(wire.NewTLV(wire.AdminTLVEmailAddress, e.Address))
+		}
+		return getAdminInfoReply(tlvList), nil
+	}
 
+	if _, hasNickName := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasNickName {
+		tlvList.Append(wire.NewTLV(wire.AdminTLVScreenNameFormatted, sess.DisplayScreenName().String()))
+		return getAdminInfoReply(tlvList), nil
 	}
 
 	return wire.SNACMessage{
@@ -96,8 +133,10 @@ func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wi
 	}, nil
 }
 
+// InfoChangeRequest handles the user changing account information
 func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error) {
-	var replyMessage = func(tag uint16, val any) wire.SNACMessage {
+	// replyMessage builds and returns an AdminChangeReply SNAC
+	var getAdminChangeReply = func(tlvList wire.TLVList) wire.SNACMessage {
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.Admin,
@@ -107,39 +146,59 @@ func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session
 			Body: wire.SNAC_0x07_0x05_AdminChangeReply{
 				Permissions: wire.AdminInfoPermissionsReadWrite,
 				TLVBlock: wire.TLVBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLV(tag, val),
-					},
+					TLVList: tlvList,
 				},
 			},
 		}
 	}
 
+	// validateProposedName ensures that the name is valid
 	var validateProposedName = func(name state.DisplayScreenName) (ok bool, errorCode uint16) {
+		// proposed name is too long
 		if len(name) > 16 {
-			// proposed name is too long
-			// todo: 16 should be defined elsewhere
 			return false, wire.AdminInfoErrorInvalidNickNameLength
-		} else if name.IdentScreenName() != sess.IdentScreenName() {
-			// proposed name does not match session name (e.g. malicious client)
+		}
+		// proposed name does not match session name (e.g. malicious client)
+		if name.IdentScreenName() != sess.IdentScreenName() {
+			return false, wire.AdminInfoErrorValidateNickName
+		}
+		// proposed name ends in a space
+		if name[len(name)-1] == 32 {
 			return false, wire.AdminInfoErrorInvalidNickName
 		}
 		return true, 0
 	}
 
+	// validateProposedEmailAddress ensures that the email address is valid
+	var validateProposedEmailAddress = func(emailAddress []byte) (e *mail.Address, errorCode uint16) {
+		/*
+			todo: pidgin/libpurple will show 'unknown error: 0xNNNN' for these error codes.
+			We could do a client check here and send wire.AdminInfoErrorDNSFail so pidgin
+			will show "given email address is invalid" instead.
+		*/
+
+		e, err := mail.ParseAddress(string(emailAddress))
+
+		// rfc 5322 basic validation
+		if err != nil {
+			return nil, wire.AdminInfoErrorInvalidEmail
+		}
+		// rfc 5521 length - local-part (64) + @ (1) + domain (255)
+		if len(e.Address) > 320 {
+			return nil, wire.AdminInfoErrorInvalidEmailLength
+		}
+		// todo: wire.AdminInfoErrorDNSFail could be sent here for an invalid domain name
+		return e, 0
+	}
+
+	tlvList := wire.TLVList{}
+
 	if sn, hasScreenNameFormatted := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasScreenNameFormatted {
 		proposedName := state.DisplayScreenName(sn)
 		if ok, errorCode := validateProposedName(proposedName); !ok {
-			return wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Admin,
-					SubGroup:  wire.AdminErr,
-					RequestID: frame.RequestID,
-				},
-				Body: wire.SNACError{
-					Code: errorCode,
-				},
-			}, nil
+			tlvList.Append(wire.NewTLV(wire.AdminTLVErrorCode, errorCode))
+			tlvList.Append(wire.NewTLV(wire.AdminTLVUrl, ""))
+			return getAdminChangeReply(tlvList), nil
 		}
 		if err := s.accountManager.UpdateDisplayScreenName(proposedName); err != nil {
 			return wire.SNACMessage{}, err
@@ -157,8 +216,42 @@ func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session
 				TLVUserInfo: sess.TLVUserInfo(),
 			},
 		})
-		return replyMessage(wire.AdminTLVScreenNameFormatted, proposedName.String()), nil
+		tlvList.Append(wire.NewTLV(wire.AdminTLVScreenNameFormatted, proposedName.String()))
+		return getAdminChangeReply(tlvList), nil
+	}
+
+	if emailAddress, hasEmailAddress := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmailAddress {
+		e, errorCode := validateProposedEmailAddress(emailAddress)
+		if errorCode != 0 {
+			tlvList.Append(wire.NewTLV(wire.AdminTLVErrorCode, errorCode))
+			tlvList.Append(wire.NewTLV(wire.AdminTLVUrl, ""))
+			return getAdminChangeReply(tlvList), nil
+
+		}
+		if err := s.accountManager.UpdateEmailAddress(e, sess.IdentScreenName()); err != nil {
+			return wire.SNACMessage{}, err
+		}
+		tlvList.Append(wire.NewTLV(wire.AdminTLVEmailAddress, e.Address))
+		return getAdminChangeReply(tlvList), nil
+	}
+
+	if regStatus, hasRegStatus := body.TLVRestBlock.Uint16(wire.AdminTLVRegistrationStatus); hasRegStatus {
+		switch regStatus {
+		case
+			wire.AdminInfoRegStatusFullDisclosure,
+			wire.AdminInfoRegStatusLimitDisclosure,
+			wire.AdminInfoRegStatusNoDisclosure:
+			if err := s.accountManager.UpdateRegStatus(regStatus, sess.IdentScreenName()); err != nil {
+				return wire.SNACMessage{}, err
+			}
+			tlvList.Append(wire.NewTLV(wire.AdminTLVRegistrationStatus, regStatus))
+			return getAdminChangeReply(tlvList), nil
+		}
+		tlvList.Append(wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidRegistrationPreference))
+		tlvList.Append(wire.NewTLV(wire.AdminTLVUrl, ""))
+		return getAdminChangeReply(tlvList), nil
 	}
+
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Admin,

+ 571 - 13
foodgroup/admin_test.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"net/mail"
 	"testing"
 
 	"github.com/mk6i/retro-aim-server/config"
@@ -21,17 +22,21 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
+		// userSession is the session of the user
+		userSession *state.Session
 		// expectOutput is the SNAC sent from the server to client
 		expectOutput wire.SNACMessage
 		// expectErr is the expected error returned
 		expectErr error
 	}{
 		{
-			name: "user confirms their account",
+			name:        "unconfirmed account sends confirmation request",
+			userSession: newTestSession("chattingchuck"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Admin,
 					SubGroup:  wire.AdminAcctConfirmRequest,
+					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x07_0x06_AdminConfirmRequest{},
 			},
@@ -39,11 +44,124 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Admin,
 					SubGroup:  wire.AdminAcctConfirmReply,
+					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
 					Status: wire.AdminAcctConfirmStatusEmailSent,
 				},
 			},
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerEmailAddressByNameParams: accountManagerEmailAddressByNameParams{
+						{
+
+							screenName: state.NewIdentScreenName("chattingchuck"),
+							emailAddress: &mail.Address{
+								Address: "chuck@aol.com",
+							},
+							err: nil,
+						},
+					},
+					accountManagerConfirmStatusByNameParams: accountManagerConfirmStatusByNameParams{
+						{
+							screenName:    state.NewIdentScreenName("chattingchuck"),
+							confirmStatus: false,
+							err:           nil,
+						},
+					},
+					accountManagerUpdateConfirmStatusParams: accountManagerUpdateConfirmStatusParams{
+						{
+							confirmStatus: true,
+							screenName:    state.NewIdentScreenName("chattingchuck"),
+							err:           nil,
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: state.NewIdentScreenName("Chatting Chuck"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "already confirmed account sends confirmation request",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminAcctConfirmRequest,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x07_0x06_AdminConfirmRequest{},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminAcctConfirmReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
+					Status: wire.AdminAcctConfirmStatusAlreadyConfirmed,
+				},
+			},
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerEmailAddressByNameParams: accountManagerEmailAddressByNameParams{
+						{
+
+							screenName: state.NewIdentScreenName("chattingchuck"),
+							emailAddress: &mail.Address{
+								Address: "chuck@aol.com",
+							},
+							err: nil,
+						},
+					},
+					accountManagerConfirmStatusByNameParams: accountManagerConfirmStatusByNameParams{
+						{
+							screenName:    state.NewIdentScreenName("chattingchuck"),
+							confirmStatus: true,
+							err:           nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "acccount with no email address sends confirmation request",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminAcctConfirmRequest,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x07_0x06_AdminConfirmRequest{},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminAcctConfirmReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
+					Status: wire.AdminAcctConfirmStatusServerError,
+				},
+			},
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerEmailAddressByNameParams: accountManagerEmailAddressByNameParams{
+						{
+
+							screenName:   state.NewIdentScreenName("chattingchuck"),
+							emailAddress: nil,
+							err:          state.ErrNoEmailAddress,
+						},
+					},
+				},
+			},
 		},
 	}
 
@@ -52,12 +170,33 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 			sessionManager := newMockSessionManager(t)
 			accountManager := newMockAccountManager(t)
 			buddyBroadcaster := newMockbuddyBroadcaster(t)
+
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerEmailAddressByNameParams {
+				accountManager.EXPECT().
+					EmailAddressByName(params.screenName).
+					Return(params.emailAddress, params.err)
+			}
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerConfirmStatusByNameParams {
+				accountManager.EXPECT().
+					ConfirmStatusByName(params.screenName).
+					Return(params.confirmStatus, params.err)
+			}
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerUpdateConfirmStatusParams {
+				accountManager.EXPECT().
+					UpdateConfirmStatus(params.confirmStatus, params.screenName).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				buddyBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, tc.userSession).
+					Return(params.err)
+			}
 			svc := AdminService{
 				sessionManager:         sessionManager,
 				accountManager:         accountManager,
 				buddyUpdateBroadcaster: buddyBroadcaster,
 			}
-			outputSNAC, err := svc.ConfirmRequest(nil, tc.inputSNAC.Frame)
+			outputSNAC, err := svc.ConfirmRequest(nil, tc.userSession, tc.inputSNAC.Frame)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
@@ -111,7 +250,18 @@ func TestAdminService_InfoQuery(t *testing.T) {
 					Permissions: wire.AdminInfoPermissionsReadWrite,
 					TLVBlock: wire.TLVBlock{
 						TLVList: wire.TLVList{
-							wire.NewTLV(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusFullDisclosure),
+							wire.NewTLV(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusLimitDisclosure),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerRegStatusByNameParams: accountManagerRegStatusByNameParams{
+						{
+							screenName: state.NewIdentScreenName("chattingchuck"),
+							regStatus:  wire.AdminInfoRegStatusLimitDisclosure,
+							err:        nil,
 						},
 					},
 				},
@@ -143,7 +293,63 @@ func TestAdminService_InfoQuery(t *testing.T) {
 					Permissions: wire.AdminInfoPermissionsReadWrite,
 					TLVBlock: wire.TLVBlock{
 						TLVList: wire.TLVList{
-							wire.NewTLV(wire.AdminTLVEmailAddress, "chattingchuck@aol.com"), // todo: get from session
+							wire.NewTLV(wire.AdminTLVEmailAddress, "chattingchuck@aol.com"),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerEmailAddressByNameParams: accountManagerEmailAddressByNameParams{
+						{
+							screenName: state.NewIdentScreenName("chattingchuck"),
+							emailAddress: &mail.Address{
+								Address: "chattingchuck@aol.com",
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "user requests account email address but not set",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoQuery,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x02_AdminInfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVEmailAddress, uint16(0x00))},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x03_AdminInfoReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVEmailAddress, ""),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerEmailAddressByNameParams: accountManagerEmailAddressByNameParams{
+						{
+							screenName:   state.NewIdentScreenName("chattingchuck"),
+							emailAddress: nil,
+							err:          state.ErrNoEmailAddress,
 						},
 					},
 				},
@@ -215,6 +421,19 @@ func TestAdminService_InfoQuery(t *testing.T) {
 			sessionManager := newMockSessionManager(t)
 			accountManager := newMockAccountManager(t)
 			buddyBroadcaster := newMockbuddyBroadcaster(t)
+
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerRegStatusByNameParams {
+				accountManager.EXPECT().
+					RegStatusByName(params.screenName).
+					Return(params.regStatus, params.err)
+			}
+
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerEmailAddressByNameParams {
+				accountManager.EXPECT().
+					EmailAddressByName(params.screenName).
+					Return(params.emailAddress, params.err)
+			}
+
 			svc := AdminService{
 				sessionManager:         sessionManager,
 				accountManager:         accountManager,
@@ -230,7 +449,7 @@ func TestAdminService_InfoQuery(t *testing.T) {
 	}
 }
 
-func TestAdminService_InfoChangeRequest(t *testing.T) {
+func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -332,16 +551,22 @@ func TestAdminService_InfoChangeRequest(t *testing.T) {
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Admin,
-					SubGroup:  wire.AdminErr,
+					SubGroup:  wire.AdminInfoChangeReply,
 					RequestID: 1337,
 				},
-				Body: wire.SNACError{
-					Code: wire.AdminInfoErrorInvalidNickNameLength,
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidNickNameLength),
+							wire.NewTLV(wire.AdminTLVUrl, ""),
+						},
+					},
 				},
 			},
 		},
 		{
-			name:        "proposed screen name does not match session's screen name (malicous client)",
+			name:        "proposed screen name does not match session screen name",
 			userSession: newTestSession("chattingchuck"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -359,11 +584,50 @@ func TestAdminService_InfoChangeRequest(t *testing.T) {
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Admin,
-					SubGroup:  wire.AdminErr,
+					SubGroup:  wire.AdminInfoChangeReply,
 					RequestID: 1337,
 				},
-				Body: wire.SNACError{
-					Code: wire.AdminInfoErrorInvalidNickName,
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorValidateNickName),
+							wire.NewTLV(wire.AdminTLVUrl, ""),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "proposed screen name ends in a space",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVScreenNameFormatted, "ChattingChuck ")},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidNickName),
+							wire.NewTLV(wire.AdminTLVUrl, ""),
+						},
+					},
 				},
 			},
 		},
@@ -388,7 +652,7 @@ func TestAdminService_InfoChangeRequest(t *testing.T) {
 					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
 						return s.IdentScreenName() == p.screenName
 					})).
-					Return(nil)
+					Return(p.err)
 			}
 
 			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
@@ -412,3 +676,297 @@ func TestAdminService_InfoChangeRequest(t *testing.T) {
 		})
 	}
 }
+
+func TestAdminService_InfoChangeRequest_EmailAddress(t *testing.T) {
+	// One case needs a 320 character long email address
+	longEmailAddress := "longemailaddress@"
+	for i := 0; i < 50; i++ {
+		longEmailAddress += "domain"
+	}
+	longEmailAddress += ".com"
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// cfg is the app configuration
+		cfg config.Config
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// userSession is the session of the user
+		userSession *state.Session
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput wire.SNACMessage
+		// expectErr is the expected error returned
+		expectErr error
+	}{
+		{
+			name:        "user changes email address successfully",
+			userSession: newTestSession("chattingchuck"),
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerUpdateEmailAddressParams: accountManagerUpdateEmailAddressParams{
+						{
+							screenName: state.NewIdentScreenName("chattingchuck"),
+							emailAddress: &mail.Address{
+								Address: "chattingchuck@aol.com",
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVEmailAddress, "chattingchuck@aol.com"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVEmailAddress, "chattingchuck@aol.com"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "proposed email address invalid rfc 5322 format",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVEmailAddress, "chattingchuck@@@@@@@aol.com"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidEmail),
+							wire.NewTLV(wire.AdminTLVUrl, ""),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "proposed email address too long",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVEmailAddress, longEmailAddress),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidEmailLength),
+							wire.NewTLV(wire.AdminTLVUrl, ""),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			sessionManager := newMockSessionManager(t)
+			accountManager := newMockAccountManager(t)
+			buddyBroadcaster := newMockbuddyBroadcaster(t)
+			messageRelayer := newMockMessageRelayer(t)
+
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerUpdateEmailAddressParams {
+				accountManager.EXPECT().
+					UpdateEmailAddress(params.emailAddress, params.screenName).
+					Return(params.err)
+			}
+
+			svc := AdminService{
+				sessionManager:         sessionManager,
+				accountManager:         accountManager,
+				buddyUpdateBroadcaster: buddyBroadcaster,
+				messageRelayer:         messageRelayer,
+			}
+			outputSNAC, err := svc.InfoChangeRequest(nil, tc.userSession, tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x07_0x04_AdminInfoChangeRequest))
+			assert.ErrorIs(t, err, tc.expectErr)
+			if tc.expectErr != nil {
+				return
+			}
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
+func TestAdminService_InfoChangeRequest_RegStatus(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// cfg is the app configuration
+		cfg config.Config
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// userSession is the session of the user
+		userSession *state.Session
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput wire.SNACMessage
+		// expectErr is the expected error returned
+		expectErr error
+	}{
+		{
+			name:        "user changes reg preference successfully",
+			userSession: newTestSession("chattingchuck"),
+			mockParams: mockParams{
+				accountManagerParams: accountManagerParams{
+					accountManagerUpdateRegStatusParams: accountManagerUpdateRegStatusParams{
+						{
+							screenName: state.NewIdentScreenName("chattingchuck"),
+							regStatus:  wire.AdminInfoRegStatusNoDisclosure,
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusNoDisclosure),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusNoDisclosure),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "proposed reg preference invalid",
+			userSession: newTestSession("chattingchuck"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVRegistrationStatus, uint16(0x1337)),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+					RequestID: 1337,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidRegistrationPreference),
+							wire.NewTLV(wire.AdminTLVUrl, ""),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			sessionManager := newMockSessionManager(t)
+			accountManager := newMockAccountManager(t)
+			buddyBroadcaster := newMockbuddyBroadcaster(t)
+			messageRelayer := newMockMessageRelayer(t)
+
+			for _, params := range tc.mockParams.accountManagerParams.accountManagerUpdateRegStatusParams {
+				accountManager.EXPECT().
+					UpdateRegStatus(params.regStatus, params.screenName).
+					Return(params.err)
+			}
+
+			svc := AdminService{
+				sessionManager:         sessionManager,
+				accountManager:         accountManager,
+				buddyUpdateBroadcaster: buddyBroadcaster,
+				messageRelayer:         messageRelayer,
+			}
+			outputSNAC, err := svc.InfoChangeRequest(nil, tc.userSession, tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x07_0x04_AdminInfoChangeRequest))
+			assert.ErrorIs(t, err, tc.expectErr)
+			if tc.expectErr != nil {
+				return
+			}
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}

+ 12 - 1
foodgroup/auth.go

@@ -25,6 +25,7 @@ func NewAuthService(
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	chatMessageRelayer ChatMessageRelayer,
+	accountManager AccountManager,
 ) *AuthService {
 	return &AuthService{
 		buddyUpdateBroadcaster: NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager),
@@ -35,6 +36,7 @@ func NewAuthService(
 		sessionManager:         sessionManager,
 		userManager:            userManager,
 		chatMessageRelayer:     chatMessageRelayer,
+		accountManager:         accountManager,
 	}
 }
 
@@ -51,6 +53,7 @@ type AuthService struct {
 	sessionManager         SessionManager
 	userManager            UserManager
 	chatRoomManager        ChatRoomRegistry
+	accountManager         AccountManager
 }
 
 // RegisterChatSession adds a user to a chat room. The authCookie param is an
@@ -87,7 +90,15 @@ func (s AuthService) RegisterBOSSession(authCookie []byte) (*state.Session, erro
 		return nil, fmt.Errorf("user not found")
 	}
 
-	return s.sessionManager.AddSession(u.DisplayScreenName), nil
+	sess := s.sessionManager.AddSession(u.DisplayScreenName)
+	// Set the unconfirmed user info flag if this account is unconfirmed
+	if confirmed, err := s.accountManager.ConfirmStatusByName(sess.IdentScreenName()); err != nil {
+		return nil, fmt.Errorf("error setting unconfirmed user flag: %w", err)
+	} else if !confirmed {
+		sess.SetUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
+	}
+
+	return sess, nil
 }
 
 // RetrieveBOSSession returns a user's existing session

+ 11 - 6
foodgroup/auth_test.go

@@ -957,7 +957,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 		Crack(authCookie).
 		Return(chatCookieBuf.Bytes(), nil)
 
-	svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, nil, cookieBaker, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, nil, cookieBaker, nil, nil, nil, nil)
 
 	have, err := svc.RegisterChatSession(authCookie)
 	assert.NoError(t, err)
@@ -984,7 +984,12 @@ func TestAuthService_RegisterBOSSession_HappyPath(t *testing.T) {
 		User(sess.IdentScreenName()).
 		Return(&state.User{DisplayScreenName: sess.DisplayScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil)
+	accountManager := newMockAccountManager(t)
+	accountManager.EXPECT().
+		ConfirmStatusByName(sess.IdentScreenName()).
+		Return(true, nil)
+
+	svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil, accountManager)
 
 	have, err := svc.RegisterBOSSession(authCookie)
 	assert.NoError(t, err)
@@ -1011,7 +1016,7 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 		User(sess.IdentScreenName()).
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil, nil)
 
 	have, err := svc.RetrieveBOSSession(authCookie)
 	assert.NoError(t, err)
@@ -1038,7 +1043,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 		User(sess.IdentScreenName()).
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil, nil)
 
 	have, err := svc.RetrieveBOSSession(authCookie)
 	assert.NoError(t, err)
@@ -1149,7 +1154,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					RemoveSession(matchSession(params.screenName))
 			}
 
-			svc := NewAuthService(config.Config{}, nil, sessionManager, nil, nil, nil, nil, nil, chatMessageRelayer)
+			svc := NewAuthService(config.Config{}, nil, sessionManager, nil, nil, nil, nil, nil, chatMessageRelayer, nil)
 			svc.SignoutChat(nil, tt.userSession)
 		})
 	}
@@ -1214,7 +1219,7 @@ func TestAuthService_Signout(t *testing.T) {
 					})).
 					Return(nil)
 			}
-			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, legacyBuddyListManager, nil, nil, nil, nil)
+			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, legacyBuddyListManager, nil, nil, nil, nil, nil)
 			svc.buddyUpdateBroadcaster = buddyUpdateBroadcaster
 
 			err := svc.Signout(nil, tt.userSession)

+ 313 - 0
foodgroup/mock_account_manager_test.go

@@ -3,6 +3,8 @@
 package foodgroup
 
 import (
+	mail "net/mail"
+
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
 )
@@ -20,6 +22,223 @@ func (_m *mockAccountManager) EXPECT() *mockAccountManager_Expecter {
 	return &mockAccountManager_Expecter{mock: &_m.Mock}
 }
 
+// ConfirmStatusByName provides a mock function with given fields: screnName
+func (_m *mockAccountManager) ConfirmStatusByName(screnName state.IdentScreenName) (bool, error) {
+	ret := _m.Called(screnName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ConfirmStatusByName")
+	}
+
+	var r0 bool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (bool, error)); ok {
+		return rf(screnName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) bool); ok {
+		r0 = rf(screnName)
+	} else {
+		r0 = ret.Get(0).(bool)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screnName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountManager_ConfirmStatusByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfirmStatusByName'
+type mockAccountManager_ConfirmStatusByName_Call struct {
+	*mock.Call
+}
+
+// ConfirmStatusByName is a helper method to define mock.On call
+//   - screnName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) ConfirmStatusByName(screnName interface{}) *mockAccountManager_ConfirmStatusByName_Call {
+	return &mockAccountManager_ConfirmStatusByName_Call{Call: _e.mock.On("ConfirmStatusByName", screnName)}
+}
+
+func (_c *mockAccountManager_ConfirmStatusByName_Call) Run(run func(screnName state.IdentScreenName)) *mockAccountManager_ConfirmStatusByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_ConfirmStatusByName_Call) Return(_a0 bool, _a1 error) *mockAccountManager_ConfirmStatusByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountManager_ConfirmStatusByName_Call) RunAndReturn(run func(state.IdentScreenName) (bool, error)) *mockAccountManager_ConfirmStatusByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// EmailAddressByName provides a mock function with given fields: screenName
+func (_m *mockAccountManager) EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for EmailAddressByName")
+	}
+
+	var r0 *mail.Address
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (*mail.Address, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *mail.Address); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*mail.Address)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountManager_EmailAddressByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EmailAddressByName'
+type mockAccountManager_EmailAddressByName_Call struct {
+	*mock.Call
+}
+
+// EmailAddressByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) EmailAddressByName(screenName interface{}) *mockAccountManager_EmailAddressByName_Call {
+	return &mockAccountManager_EmailAddressByName_Call{Call: _e.mock.On("EmailAddressByName", screenName)}
+}
+
+func (_c *mockAccountManager_EmailAddressByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountManager_EmailAddressByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_EmailAddressByName_Call) Return(_a0 *mail.Address, _a1 error) *mockAccountManager_EmailAddressByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountManager_EmailAddressByName_Call) RunAndReturn(run func(state.IdentScreenName) (*mail.Address, error)) *mockAccountManager_EmailAddressByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegStatusByName provides a mock function with given fields: screenName
+func (_m *mockAccountManager) RegStatusByName(screenName state.IdentScreenName) (uint16, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegStatusByName")
+	}
+
+	var r0 uint16
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (uint16, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) uint16); ok {
+		r0 = rf(screenName)
+	} else {
+		r0 = ret.Get(0).(uint16)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountManager_RegStatusByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegStatusByName'
+type mockAccountManager_RegStatusByName_Call struct {
+	*mock.Call
+}
+
+// RegStatusByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) RegStatusByName(screenName interface{}) *mockAccountManager_RegStatusByName_Call {
+	return &mockAccountManager_RegStatusByName_Call{Call: _e.mock.On("RegStatusByName", screenName)}
+}
+
+func (_c *mockAccountManager_RegStatusByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountManager_RegStatusByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_RegStatusByName_Call) Return(_a0 uint16, _a1 error) *mockAccountManager_RegStatusByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountManager_RegStatusByName_Call) RunAndReturn(run func(state.IdentScreenName) (uint16, error)) *mockAccountManager_RegStatusByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpdateConfirmStatus provides a mock function with given fields: confirmStatus, screenName
+func (_m *mockAccountManager) UpdateConfirmStatus(confirmStatus bool, screenName state.IdentScreenName) error {
+	ret := _m.Called(confirmStatus, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpdateConfirmStatus")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(bool, state.IdentScreenName) error); ok {
+		r0 = rf(confirmStatus, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockAccountManager_UpdateConfirmStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateConfirmStatus'
+type mockAccountManager_UpdateConfirmStatus_Call struct {
+	*mock.Call
+}
+
+// UpdateConfirmStatus is a helper method to define mock.On call
+//   - confirmStatus bool
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) UpdateConfirmStatus(confirmStatus interface{}, screenName interface{}) *mockAccountManager_UpdateConfirmStatus_Call {
+	return &mockAccountManager_UpdateConfirmStatus_Call{Call: _e.mock.On("UpdateConfirmStatus", confirmStatus, screenName)}
+}
+
+func (_c *mockAccountManager_UpdateConfirmStatus_Call) Run(run func(confirmStatus bool, screenName state.IdentScreenName)) *mockAccountManager_UpdateConfirmStatus_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(bool), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateConfirmStatus_Call) Return(_a0 error) *mockAccountManager_UpdateConfirmStatus_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateConfirmStatus_Call) RunAndReturn(run func(bool, state.IdentScreenName) error) *mockAccountManager_UpdateConfirmStatus_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // UpdateDisplayScreenName provides a mock function with given fields: displayScreenName
 func (_m *mockAccountManager) UpdateDisplayScreenName(displayScreenName state.DisplayScreenName) error {
 	ret := _m.Called(displayScreenName)
@@ -66,6 +285,100 @@ func (_c *mockAccountManager_UpdateDisplayScreenName_Call) RunAndReturn(run func
 	return _c
 }
 
+// UpdateEmailAddress provides a mock function with given fields: emailAddress, screenName
+func (_m *mockAccountManager) UpdateEmailAddress(emailAddress *mail.Address, screenName state.IdentScreenName) error {
+	ret := _m.Called(emailAddress, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpdateEmailAddress")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*mail.Address, state.IdentScreenName) error); ok {
+		r0 = rf(emailAddress, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockAccountManager_UpdateEmailAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateEmailAddress'
+type mockAccountManager_UpdateEmailAddress_Call struct {
+	*mock.Call
+}
+
+// UpdateEmailAddress is a helper method to define mock.On call
+//   - emailAddress *mail.Address
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) UpdateEmailAddress(emailAddress interface{}, screenName interface{}) *mockAccountManager_UpdateEmailAddress_Call {
+	return &mockAccountManager_UpdateEmailAddress_Call{Call: _e.mock.On("UpdateEmailAddress", emailAddress, screenName)}
+}
+
+func (_c *mockAccountManager_UpdateEmailAddress_Call) Run(run func(emailAddress *mail.Address, screenName state.IdentScreenName)) *mockAccountManager_UpdateEmailAddress_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(*mail.Address), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateEmailAddress_Call) Return(_a0 error) *mockAccountManager_UpdateEmailAddress_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateEmailAddress_Call) RunAndReturn(run func(*mail.Address, state.IdentScreenName) error) *mockAccountManager_UpdateEmailAddress_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpdateRegStatus provides a mock function with given fields: regStatus, screenName
+func (_m *mockAccountManager) UpdateRegStatus(regStatus uint16, screenName state.IdentScreenName) error {
+	ret := _m.Called(regStatus, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpdateRegStatus")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(uint16, state.IdentScreenName) error); ok {
+		r0 = rf(regStatus, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockAccountManager_UpdateRegStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRegStatus'
+type mockAccountManager_UpdateRegStatus_Call struct {
+	*mock.Call
+}
+
+// UpdateRegStatus is a helper method to define mock.On call
+//   - regStatus uint16
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) UpdateRegStatus(regStatus interface{}, screenName interface{}) *mockAccountManager_UpdateRegStatus_Call {
+	return &mockAccountManager_UpdateRegStatus_Call{Call: _e.mock.On("UpdateRegStatus", regStatus, screenName)}
+}
+
+func (_c *mockAccountManager_UpdateRegStatus_Call) Run(run func(regStatus uint16, screenName state.IdentScreenName)) *mockAccountManager_UpdateRegStatus_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint16), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateRegStatus_Call) Return(_a0 error) *mockAccountManager_UpdateRegStatus_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateRegStatus_Call) RunAndReturn(run func(uint16, state.IdentScreenName) error) *mockAccountManager_UpdateRegStatus_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // newMockAccountManager creates a new instance of mockAccountManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
 // The first argument is typically a *testing.T value.
 func newMockAccountManager(t interface {

+ 55 - 0
foodgroup/test_helpers.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"net/mail"
 	"time"
 
 	"github.com/stretchr/testify/mock"
@@ -302,6 +303,12 @@ type cookieIssuerParams []struct {
 // accountManager methods
 type accountManagerParams struct {
 	accountManagerUpdateDisplayScreenNameParams
+	accountManagerUpdateEmailAddressParams
+	accountManagerEmailAddressByNameParams
+	accountManagerUpdateRegStatusParams
+	accountManagerRegStatusByNameParams
+	accountManagerUpdateConfirmStatusParams
+	accountManagerConfirmStatusByNameParams
 }
 
 // accountManagerUpdateDisplayScreenNameParams is the list of parameters passed at the mock
@@ -311,6 +318,54 @@ type accountManagerUpdateDisplayScreenNameParams []struct {
 	err               error
 }
 
+// accountManagerUpdateEmailAddressParams is the list of parameters passed at the mock
+// accountManager.UpdateEmailAddress call site
+type accountManagerUpdateEmailAddressParams []struct {
+	emailAddress *mail.Address
+	screenName   state.IdentScreenName
+	err          error
+}
+
+// accountManagerEmailAddressByNameParams is the list of parameters passed at the mock
+// accountManager.EmailAddressByName call site
+type accountManagerEmailAddressByNameParams []struct {
+	screenName   state.IdentScreenName
+	emailAddress *mail.Address
+	err          error
+}
+
+// accountManagerUpdateRegStatusParams is the list of parameters passed at the mock
+// accountManager.UpdateRegStatus call site
+type accountManagerUpdateRegStatusParams []struct {
+	regStatus  uint16
+	screenName state.IdentScreenName
+	err        error
+}
+
+// accountManagerRegStatusByNameParams is the list of parameters passed at the mock
+// accountManager.RegStatusByName call site
+type accountManagerRegStatusByNameParams []struct {
+	screenName state.IdentScreenName
+	regStatus  uint16
+	err        error
+}
+
+// accountManagerUpdateConfirmStatusParams is the list of parameters passed at the mock
+// accountManager.UpdateConfirmStatus call site
+type accountManagerUpdateConfirmStatusParams []struct {
+	confirmStatus bool
+	screenName    state.IdentScreenName
+	err           error
+}
+
+// accountManagerConfirmStatusByNameParams is the list of parameters passed at the mock
+// accountManager.ConfirmStatusByName call site
+type accountManagerConfirmStatusByNameParams []struct {
+	screenName    state.IdentScreenName
+	confirmStatus bool
+	err           error
+}
+
 // buddyBroadcasterParams is a helper struct that contains mock parameters for
 // buddyBroadcaster methods
 type buddyBroadcasterParams struct {

+ 7 - 0
foodgroup/types.go

@@ -35,6 +35,7 @@ package foodgroup
 
 import (
 	"context"
+	"net/mail"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
@@ -164,4 +165,10 @@ type buddyBroadcaster interface {
 
 type AccountManager interface {
 	UpdateDisplayScreenName(displayScreenName state.DisplayScreenName) error
+	UpdateEmailAddress(emailAddress *mail.Address, screenName state.IdentScreenName) error
+	EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error)
+	UpdateRegStatus(regStatus uint16, screenName state.IdentScreenName) error
+	RegStatusByName(screenName state.IdentScreenName) (uint16, error)
+	UpdateConfirmStatus(confirmStatus bool, screenName state.IdentScreenName) error
+	ConfirmStatusByName(screnName state.IdentScreenName) (bool, error)
 }

+ 3 - 3
server/oscar/handler/admin.go

@@ -27,13 +27,13 @@ type AdminHandler struct {
 }
 
 type AdminService interface {
-	ConfirmRequest(_ context.Context, frame wire.SNACFrame) (wire.SNACMessage, error)
+	ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error)
 	InfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error)
 	InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
 }
 
-func (rt AdminHandler) ConfirmRequest(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, inFrame)
+func (rt AdminHandler) ConfirmRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, sess, inFrame)
 	if err != nil {
 		return err
 	}

+ 2 - 2
server/oscar/handler/admin_test.go

@@ -31,7 +31,7 @@ func TestAdminHandler_ConfirmRequest(t *testing.T) {
 
 	svc := newMockAdminService(t)
 	svc.EXPECT().
-		ConfirmRequest(mock.Anything, input.Frame).
+		ConfirmRequest(nil, mock.Anything, input.Frame).
 		Return(output, nil)
 
 	h := NewAdminHandler(slog.Default(), svc)
@@ -77,7 +77,7 @@ func TestAdminHandler_InfoQuery_RegistrationStatus(t *testing.T) {
 
 	svc := newMockAdminService(t)
 	svc.EXPECT().
-		ConfirmRequest(mock.Anything, input.Frame).
+		ConfirmRequest(mock.Anything, mock.Anything, input.Frame).
 		Return(output, nil)
 
 	h := NewAdminHandler(slog.Default(), svc)

+ 16 - 15
server/oscar/handler/mock_admin_test.go

@@ -24,9 +24,9 @@ func (_m *mockAdminService) EXPECT() *mockAdminService_Expecter {
 	return &mockAdminService_Expecter{mock: &_m.Mock}
 }
 
-// ConfirmRequest provides a mock function with given fields: _a0, frame
-func (_m *mockAdminService) ConfirmRequest(_a0 context.Context, frame wire.SNACFrame) (wire.SNACMessage, error) {
-	ret := _m.Called(_a0, frame)
+// ConfirmRequest provides a mock function with given fields: ctx, sess, frame
+func (_m *mockAdminService) ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, frame)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ConfirmRequest")
@@ -34,17 +34,17 @@ func (_m *mockAdminService) ConfirmRequest(_a0 context.Context, frame wire.SNACF
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) (wire.SNACMessage, error)); ok {
-		return rf(_a0, frame)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, frame)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
-		r0 = rf(_a0, frame)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, frame)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame) error); ok {
-		r1 = rf(_a0, frame)
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame) error); ok {
+		r1 = rf(ctx, sess, frame)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -58,15 +58,16 @@ type mockAdminService_ConfirmRequest_Call struct {
 }
 
 // ConfirmRequest is a helper method to define mock.On call
-//   - _a0 context.Context
+//   - ctx context.Context
+//   - sess *state.Session
 //   - frame wire.SNACFrame
-func (_e *mockAdminService_Expecter) ConfirmRequest(_a0 interface{}, frame interface{}) *mockAdminService_ConfirmRequest_Call {
-	return &mockAdminService_ConfirmRequest_Call{Call: _e.mock.On("ConfirmRequest", _a0, frame)}
+func (_e *mockAdminService_Expecter) ConfirmRequest(ctx interface{}, sess interface{}, frame interface{}) *mockAdminService_ConfirmRequest_Call {
+	return &mockAdminService_ConfirmRequest_Call{Call: _e.mock.On("ConfirmRequest", ctx, sess, frame)}
 }
 
-func (_c *mockAdminService_ConfirmRequest_Call) Run(run func(_a0 context.Context, frame wire.SNACFrame)) *mockAdminService_ConfirmRequest_Call {
+func (_c *mockAdminService_ConfirmRequest_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame)) *mockAdminService_ConfirmRequest_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame))
 	})
 	return _c
 }
@@ -76,7 +77,7 @@ func (_c *mockAdminService_ConfirmRequest_Call) Return(_a0 wire.SNACMessage, _a1
 	return _c
 }
 
-func (_c *mockAdminService_ConfirmRequest_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) (wire.SNACMessage, error)) *mockAdminService_ConfirmRequest_Call {
+func (_c *mockAdminService_ConfirmRequest_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame) (wire.SNACMessage, error)) *mockAdminService_ConfirmRequest_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 17 - 0
state/migrations/0005_user_settings.down.sql

@@ -0,0 +1,17 @@
+CREATE TABLE users_backup
+(
+    identScreenName   VARCHAR(16) PRIMARY KEY,
+    displayScreenName TEXT,
+    authKey           TEXT,
+    strongMD5Pass     TEXT,
+    weakMD5Pass       TEXT
+);
+
+INSERT INTO users_backup (identScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass)
+SELECT dentScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass
+FROM users;
+
+DROP TABLE users;
+
+ALTER TABLE users_backup
+    RENAME TO users;

+ 6 - 0
state/migrations/0005_user_settings.up.sql

@@ -0,0 +1,6 @@
+ALTER TABLE users
+    ADD COLUMN confirmStatus     BOOL DEFAULT FALSE;
+ALTER TABLE users
+    ADD COLUMN emailAddress      VARCHAR(320) NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN regStatus         INT NOT NULL DEFAULT 3;

+ 40 - 12
state/session.go

@@ -39,20 +39,45 @@ type Session struct {
 	signonTime        time.Time
 	stopCh            chan struct{}
 	warning           uint16
+	userInfoFlags     uint16
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
 // up to 1000 pending messages before blocking.
 func NewSession() *Session {
 	return &Session{
-		msgCh:      make(chan wire.SNACMessage, 1000),
-		nowFn:      time.Now,
-		stopCh:     make(chan struct{}),
-		signonTime: time.Now(),
-		caps:       make([][16]byte, 0),
+		msgCh:         make(chan wire.SNACMessage, 1000),
+		nowFn:         time.Now,
+		stopCh:        make(chan struct{}),
+		signonTime:    time.Now(),
+		caps:          make([][16]byte, 0),
+		userInfoFlags: wire.OServiceUserFlagOSCARFree,
 	}
 }
 
+// SetUserInfoFlag sets a flag to and returns UserInfoFlags
+func (s *Session) SetUserInfoFlag(flag uint16) (flags uint16) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.userInfoFlags |= flag
+	return s.userInfoFlags
+}
+
+// ClearUserInfoFlag clear a flag from and returns UserInfoFlags
+func (s *Session) ClearUserInfoFlag(flag uint16) (flags uint16) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.userInfoFlags &^= flag
+	return s.userInfoFlags
+}
+
+// UserInfoFlags returns UserInfoFlags
+func (s *Session) UserInfoFlags() (flags uint16) {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.userInfoFlags
+}
+
 // IncrementWarning increments the user's warning level. To decrease, pass a
 // negative increment value.
 func (s *Session) IncrementWarning(incr uint16) {
@@ -126,10 +151,17 @@ func (s *Session) UnsetIdle() {
 	s.idle = false
 }
 
-// SetAwayMessage sets the user's away message.
+// SetAwayMessage sets the user's away message, and
+// sets or clears the OServiceUserFlagUnavailable flag
 func (s *Session) SetAwayMessage(awayMessage string) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
+	if awayMessage != "" {
+		s.userInfoFlags |= wire.OServiceUserFlagUnavailable
+	} else {
+		s.userInfoFlags &^= wire.OServiceUserFlagUnavailable
+	}
+
 	s.awayMessage = awayMessage
 }
 
@@ -188,12 +220,8 @@ func (s *Session) userInfo() wire.TLVList {
 	// sign-in timestamp
 	tlvs.Append(wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(s.signonTime.Unix())))
 
-	// away message status
-	if s.awayMessage != "" {
-		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree|wire.OServiceUserFlagUnavailable))
-	} else {
-		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree))
-	}
+	// user info flags
+	tlvs.Append(wire.NewTLV(wire.OServiceUserInfoUserFlags, s.UserInfoFlags()))
 
 	// reflects invisibility toggle status back to toggling client
 	if s.invisible {

+ 2 - 0
state/user.go

@@ -18,6 +18,8 @@ var (
 	ErrDupUser = errors.New("user already exists")
 	// ErrNoUser indicates that a user does not exist.
 	ErrNoUser = errors.New("user does not exist")
+	// ErrNoEmail indicates that a user has not set an email address.
+	ErrNoEmailAddress = errors.New("user has no email address")
 )
 
 const (

+ 86 - 0
state/user_store.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"io/fs"
 	"net/http"
+	"net/mail"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/wire"
@@ -598,3 +599,88 @@ func (f SQLiteUserStore) UpdateDisplayScreenName(displayScreenName DisplayScreen
 	_, err := f.db.Exec(q, displayScreenName.String(), displayScreenName.IdentScreenName().String())
 	return err
 }
+
+// UpdateEmailAddress updates the user's EmailAddress
+func (f SQLiteUserStore) UpdateEmailAddress(emailAddress *mail.Address, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET emailAddress = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, emailAddress.Address, screenName.String())
+	return err
+}
+
+// EmailAddressByName retrieves the user's EmailAddress
+func (f SQLiteUserStore) EmailAddressByName(screenName IdentScreenName) (*mail.Address, error) {
+	q := `
+		SELECT emailAddress
+		FROM users
+		WHERE identScreenName = ?
+	`
+	var emailAddress string
+	err := f.db.QueryRow(q, screenName.String()).Scan(&emailAddress)
+	// username isn't found for some reason
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		return nil, err
+	}
+	e, err := mail.ParseAddress(emailAddress)
+	if err != nil {
+		return nil, fmt.Errorf("%w: %w", ErrNoEmailAddress, err)
+	}
+	return e, nil
+}
+
+// UpdateRegStatus updates the user's registration status preference
+func (f SQLiteUserStore) UpdateRegStatus(regStatus uint16, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET regStatus = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, regStatus, screenName.String())
+	return err
+}
+
+// RegStatusByName retrieves the user's registration status preference
+func (f SQLiteUserStore) RegStatusByName(screenName IdentScreenName) (uint16, error) {
+	q := `
+		SELECT regStatus
+		FROM users
+		WHERE identScreenName = ?
+	`
+	var regStatus uint16
+	err := f.db.QueryRow(q, screenName.String()).Scan(&regStatus)
+	// username isn't found for some reason
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		return 0, err
+	}
+	return regStatus, nil
+}
+
+// UpdateConfirmStatus updates the user's confirmation status
+func (f SQLiteUserStore) UpdateConfirmStatus(confirmStatus bool, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET confirmStatus = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, confirmStatus, screenName.String())
+	return err
+}
+
+// ConfirmStatusByName retrieves the user's confirmation status
+func (f SQLiteUserStore) ConfirmStatusByName(screenName IdentScreenName) (bool, error) {
+	q := `
+		SELECT confirmStatus
+		FROM users
+		WHERE identScreenName = ?
+	`
+	var confirmStatus bool
+	err := f.db.QueryRow(q, screenName.String()).Scan(&confirmStatus)
+	// username isn't found for some reason
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		return false, err
+	}
+	return confirmStatus, nil
+}

+ 26 - 8
wire/snacs.go

@@ -134,12 +134,13 @@ const (
 	OServiceBartQuery2        uint16 = 0x0022
 	OServiceBartReply2        uint16 = 0x0023
 
-	OServiceUserInfoUserFlags uint16 = 0x01
-	OServiceUserInfoSignonTOD uint16 = 0x03
-	OServiceUserInfoIdleTime  uint16 = 0x04
-	OServiceUserInfoStatus    uint16 = 0x06
-	OServiceUserInfoOscarCaps uint16 = 0x0D
-	OServiceUserInfoBARTInfo  uint16 = 0x1D
+	OServiceUserInfoUserFlags  uint16 = 0x01
+	OServiceUserInfoSignonTOD  uint16 = 0x03
+	OServiceUserInfoIdleTime   uint16 = 0x04
+	OServiceUserInfoStatus     uint16 = 0x06
+	OServiceUserInfoOscarCaps  uint16 = 0x0D
+	OServiceUserInfoBARTInfo   uint16 = 0x1D
+	OServiceUserInfoUserFlags2 uint16 = 0x1F
 
 	OServiceUserStatusAvailable         uint32 = 0x00000000 // user is available
 	OServiceUserStatusAway              uint32 = 0x00000001 // user is away
@@ -154,8 +155,25 @@ const (
 	OServiceUserStatusICQHomePage       uint32 = 0x00200000
 	OServiceUserStatusDirectRequireAuth uint32 = 0x10000000
 
-	OServiceUserFlagOSCARFree   uint16 = 0x0010 // AIM (not AOL) account
-	OServiceUserFlagUnavailable uint16 = 0x0020 // user is away
+	OServiceUserFlagUnconfirmed    uint16 = 0x0001 // Unconfirmed account
+	OServiceUserFlagAdministrator  uint16 = 0x0002 // Server Administrator
+	OServiceUserFlagAOL            uint16 = 0x0004 // AOL (staff?) account
+	OServiceUserFlagOSCARPay       uint16 = 0x0008 // Commercial account
+	OServiceUserFlagOSCARFree      uint16 = 0x0010 // AIM (not AOL) account
+	OServiceUserFlagUnavailable    uint16 = 0x0020 // user is away
+	OServiceUserFlagICQ            uint16 = 0x0040 // ICQ user (OServiceUserFlagOSCARFree should also be set)
+	OServiceUserFlagWireless       uint16 = 0x0080 // On mobile device
+	OServiceUserFlagInternal       uint16 = 0x0100 // Internal account
+	OServiceUserFlagFish           uint16 = 0x0200 // IM forwarding enabled
+	OServiceUserFlagBot            uint16 = 0x0400 // Bot account
+	OServiceUserFlagBeast          uint16 = 0x0800 // Unknown
+	OServiceUserFlagOneWayWireless uint16 = 0x1000 // On one way mobile device
+	OServiceUserFlagOfficial       uint16 = 0x2000 // Unknown
+
+	OServiceUserFlag2BuddyMatchDirect   uint32 = 0x00010000 // Unknown
+	OServiceUserFlag2BuddyMatchIndirect uint32 = 0x00020000 // Unknown
+	OServiceUserFlag2NoKnockKnock       uint32 = 0x00040000 // Sender is safe
+	OServiceUserFlag2ForwardMobile      uint32 = 0x00080000 // Forward to mobile if no acive session
 
 	OServicePrivacyFlagIdle   uint32 = 0x00000001
 	OServicePrivacyFlagMember uint32 = 0x00000002