Просмотр исходного кода

support offline messages in AIM 6/7

check message limit before sending offline msg

add fk constraints to make sure user exists

add function to parse buddy preference bitmask

check feedbag prefs before sending offline IM

refactor offline IM check, always check for TLV 0x06

I discovered that AIM 6 will send TLV 0x06 if the intent is to send
and offline message, just like ICQ. The TLV is only sent, however, if
the recipient is on the user's buddy list AND they have seen them online
at least once.

implement offline message retrieval

delete stored messages after retrieval

send aol system msg for offline msgs at signon

add additional unit tests
Mike 8 месяцев назад
Родитель
Сommit
8db68f1c0a

+ 4 - 0
cmd/server/factory.go

@@ -87,6 +87,7 @@ func MakeCommonDeps() (Container, error) {
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
 		c.sqLiteUserStore,
+		c.sqLiteUserStore,
 		c.snacRateLimits,
 		c.logger,
 	)
@@ -289,6 +290,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.snacRateLimits,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 	)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 	statsService := foodgroup.NewStatsService()
@@ -428,6 +430,7 @@ func TOC(deps Container) *toc.Server {
 				deps.snacRateLimits,
 				deps.chatSessionManager,
 				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
 			),
 			PermitDenyService: foodgroup.NewPermitDenyService(
 				deps.sqLiteUserStore,
@@ -526,6 +529,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.snacRateLimits,
 			deps.chatSessionManager,
 			deps.sqLiteUserStore,
+			deps.sqLiteUserStore,
 		),
 		PermitDenyService: foodgroup.NewPermitDenyService(
 			deps.sqLiteUserStore,

+ 1 - 0
foodgroup/auth.go

@@ -129,6 +129,7 @@ func (s AuthService) RegisterBOSSession(ctx context.Context, serverCookie state.
 	// set string containing OSCAR client name and version
 	sess.SetClientID(serverCookie.ClientID)
 	sess.SetMemberSince(time.Now())
+	sess.SetOfflineMsgCount(u.OfflineMsgCount)
 
 	bartID, err := s.bartItemManager.BuddyIconMetadata(ctx, sess.IdentScreenName())
 	if err != nil {

+ 121 - 1
foodgroup/feedbag.go

@@ -179,7 +179,7 @@ func (s FeedbagService) UpsertItem(ctx context.Context, sess *state.Session, inF
 	for _, item := range items {
 		// don't let users block themselves, it causes the AIM client to go
 		// into a weird state.
-		if item.ClassID == 3 && state.NewIdentScreenName(item.Name) == sess.IdentScreenName() {
+		if item.ClassID == wire.FeedbagClassIDDeny && state.NewIdentScreenName(item.Name) == sess.IdentScreenName() {
 			return wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Feedbag,
@@ -421,3 +421,123 @@ func setSessionBuddyPrefs(items []wire.FeedbagItem, sess *state.Session) {
 		}
 	}
 }
+
+// FeedbagBuddyPref returns a pref value stored in the user's feedbag.
+//
+// Preferences are binary values stored in a logical bitmask spanning 2
+// physical bitmasks. Each preference value is a position in the logical
+// bitmask.
+//
+// The first bitmask (BuddyPrefs) is fixed-length of 32 bits (4 bytes).
+// It's 0-offset: pref 1 is at offset 1, pref 2 at offset 2, etc. The most
+// significant bit is on the right side.
+//
+// The second bitmask (BuddyPrefs2) is of an unbounded length. The values
+// are at a position relative to the beginning at BuddyPrefs1. The most
+// significant bit is on the left side.
+//
+// Items 1-31 are located in BuddyPrefs:
+//
+//	Item #1:
+//	00000000 00000000 00000000 00000010 (BuddyPrefs)
+//	                                 ^ offset 1, bit 2
+//	00000000 00000000 00000000 00000000 (BuddyPrefs2)
+//
+//	Item #31:
+//	10000000 00000000 00000000 00000000 (BuddyPrefs)
+//	^ offset 31, bit 32
+//	00000000 00000000 00000000 00000000 (BuddyPrefs2)
+//
+// Items 32+ are located in BuddyPrefs. To find the offset, calculate (Item #)-33.
+// For example, item 52 is located at offset 19.
+//
+//	Item #52:
+//	00000000 00000000 00000000 00000000 (BuddyPrefs)
+//	00000000 00000000 00010000 00000000 (BuddyPrefs2)
+//	                     ^ offset 19, bit 52
+//
+// There is a weird edge case for items 32 and 33 that is either a bug caused
+// by the transition from offset to positional-based indexing, or a
+// misunderstanding on my part: both items fall under offset 0 in BuddyPrefs2.
+//
+//	Item #32:
+//	00000000 00000000 00000000 00000000 (BuddyPrefs)
+//	10000000 00000000 00000000 00000000 (BuddyPrefs2)
+//	^ offset 0, bit 33
+//
+//	Item #33:
+//	00000000 00000000 00000000 00000000 (BuddyPrefs)
+//	10000000 00000000 00000000 00000000 (BuddyPrefs2)
+//	^ offset 0, bit 33
+//
+// For each logical bitmask, there are 2 physical bitmasks. The first contains
+// the set values, and the second contains the valid bitmask positions. I guess
+// this was done to remove ambiguity about an unset position: i.e. does an unset
+// value mean false or null?
+//
+// The bitmasks are present in 4 TLVs:
+//
+// - 0x00C9: FeedbagAttributesBuddyPrefs
+// - 0x00D6: FeedbagAttributesBuddyPrefsValid
+// - 0x00D7: FeedbagAttributesBuddyPrefs2
+// - 0x00D8: FeedbagAttributesBuddyPrefs2Valid
+//
+// For a given item, this function returns whether the preference number is
+// available in the bitmask (valid) and what the value of it is (value).
+func feedbagBuddyPref(prefNum uint16, list wire.TLVList) (valid bool, value bool) {
+	offset := int(prefNum)
+
+	// value is in BuddyPrefs; the most significant bit is on the right side
+	if offset < 32 {
+		buddyPrefValid, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefsValid)
+		if !ok {
+			return false, false
+		}
+		buddyPrefEnabled, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefs)
+		if !ok {
+			return false, false
+		}
+
+		index := (len(buddyPrefValid) - 1) - (offset / 8)
+		if index >= len(buddyPrefValid) || index >= len(buddyPrefEnabled) {
+			return false, false
+		}
+
+		bitOffset := offset % 8
+		mask := byte(1 << bitOffset)
+
+		valid = buddyPrefValid[index]&mask != 0
+		value = buddyPrefEnabled[index]&mask != 0
+
+		return valid, value
+	}
+
+	// value is in BuddyPrefs2; the most significant bit is on the left side
+	if prefNum == 32 {
+		offset = 0 // account for transition from offset-based to position-based
+	} else {
+		offset -= 33
+	}
+
+	buddyPrefValid, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefs2Valid)
+	if !ok {
+		return false, false
+	}
+	buddyPrefEnabled, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefs2)
+	if !ok {
+		return false, false
+	}
+
+	index := offset / 8
+	if index >= len(buddyPrefValid) || index >= len(buddyPrefEnabled) {
+		return false, false
+	}
+
+	bitOffset := offset % 8
+	mask := byte(0x80) >> bitOffset
+
+	valid = buddyPrefValid[index]&mask != 0
+	value = buddyPrefEnabled[index]&mask != 0
+
+	return valid, value
+}

+ 163 - 0
foodgroup/feedbag_test.go

@@ -1591,3 +1591,166 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 		})
 	}
 }
+
+func TestFeedbagBuddyPref(t *testing.T) {
+	tests := []struct {
+		name      string
+		itemType  uint16
+		list      wire.TLVList
+		wantValid bool
+		wantValue bool
+	}{
+		{
+			name:     "offline messages disabled",
+			itemType: wire.FeedbagBuddyPrefsAcceptOfflineIM,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 1}},
+			},
+			wantValid: true,
+			wantValue: false,
+		},
+		{
+			name:     "offline messages disabled",
+			itemType: wire.FeedbagBuddyPrefsAcceptOfflineIM,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 1}},
+			},
+			wantValid: true,
+			wantValue: false,
+		},
+		{
+			name:     "offline messages disabled, extra padding",
+			itemType: wire.FeedbagBuddyPrefsAcceptOfflineIM,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17, 0, 0}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 1, 0, 0}},
+			},
+			wantValid: true,
+			wantValue: false,
+		},
+		{
+			name:     "offline messages enabled",
+			itemType: wire.FeedbagBuddyPrefsAcceptOfflineIM,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			name:     "offline messages enabled",
+			itemType: wire.FeedbagBuddyPrefsAcceptOfflineIM,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17, 0, 0}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17, 0, 0}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			name:     "typing events enabled, with padding",
+			itemType: wire.FeedbagBuddyPrefsAcceptOfflineIM,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 64, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 64, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			name:     "typing events enabled, without padding",
+			itemType: 22,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{64, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{64, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{17}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			name:     "typing events disabled, with padding",
+			itemType: 22,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 64, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+			},
+			wantValid: true,
+			wantValue: false,
+		},
+		{
+			name:     "typing events disabled, without padding",
+			itemType: 22,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{64, 24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{24, 64}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{17}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{17}},
+			},
+			wantValid: true,
+			wantValue: false,
+		},
+		{
+			name:     "show friendly IMs enabled (dupe of disclose radio)",
+			itemType: 32,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0x00, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0x00, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0x80, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0x80, 0x00, 0x00, 0x00}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			name:     "disclose radio enabled (dupe of show friendly IMs)",
+			itemType: 33,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0x00, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0x00, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0x80, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0x80, 0x00, 0x00, 0x00}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			name:     "show capabilities enabled",
+			itemType: 34,
+			list: wire.TLVList{
+				{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0x00, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0x00, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0x40, 0x00, 0x00, 0x00}},
+				{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0x40, 0x00, 0x00, 0x00}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			valid, value := feedbagBuddyPref(tt.itemType, tt.list)
+			assert.Equal(t, tt.wantValid, valid)
+			assert.Equal(t, tt.wantValue, value)
+		})
+	}
+}

+ 18 - 0
foodgroup/helpers_test.go

@@ -65,6 +65,7 @@ type offlineMessageManagerParams struct {
 	deleteMessagesParams
 	retrieveMessagesParams
 	saveMessageParams
+	setOfflineMsgCountParams
 }
 
 // deleteMessagesParams is the list of parameters passed at the mock
@@ -86,9 +87,18 @@ type retrieveMessagesParams []struct {
 // OfflineMessageManager.SaveMessage call site
 type saveMessageParams []struct {
 	offlineMessageIn state.OfflineMessage
+	countOut         int
 	err              error
 }
 
+// setOfflineMsgCountParams is the list of parameters passed at the mock
+// OfflineMessageManager.SetOfflineMsgCount call site
+type setOfflineMsgCountParams []struct {
+	screenName state.IdentScreenName
+	count      int
+	err        error
+}
+
 // sessionRetrieverParams is a helper struct that contains mock parameters for
 // SessionRetriever methods
 type sessionRetrieverParams struct {
@@ -335,6 +345,7 @@ type useParams []struct {
 type feedbagParams []struct {
 	screenName state.IdentScreenName
 	results    []wire.FeedbagItem
+	err        error
 }
 
 // feedbagLastModifiedParams is the list of parameters passed at the mock
@@ -847,6 +858,13 @@ func sessOptBuddyIcon(icon wire.BARTID) func(session *state.Session) {
 	}
 }
 
+// sessOptOfflineMsgCount sets the offline message count on the session object.
+func sessOptOfflineMsgCount(count int) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetOfflineMsgCount(count)
+	}
+}
+
 // newTestSession creates a session object with 0 or more functional options
 // applied
 func newTestSession(screenName state.DisplayScreenName, options ...func(session *state.Session)) *state.Session {

+ 173 - 56
foodgroup/icbm.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"log/slog"
 	"sync"
@@ -29,21 +30,24 @@ func NewICBMService(
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	userManager UserManager,
+	feedbagManager FeedbagManager,
 	snacRateLimits wire.SNACRateLimits,
 	logger *slog.Logger,
 ) *ICBMService {
 	return &ICBMService{
-		relationshipFetcher: relationshipFetcher,
-		buddyBroadcaster:    newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		messageRelayer:      messageRelayer,
-		offlineMessageSaver: offlineMessageSaver,
-		userManager:         userManager,
-		timeNow:             time.Now,
-		sessionRetriever:    sessionRetriever,
-		snacRateLimits:      snacRateLimits,
-		convoTracker:        newConvoTracker(),
-		logger:              logger,
-		interval:            rateDecayInterval,
+		relationshipFetcher:   relationshipFetcher,
+		buddyBroadcaster:      newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		messageRelayer:        messageRelayer,
+		offlineMessageSaver:   offlineMessageSaver,
+		offlineMessageManager: offlineMessageSaver,
+		userManager:           userManager,
+		feedbagManager:        feedbagManager,
+		timeNow:               time.Now,
+		sessionRetriever:      sessionRetriever,
+		snacRateLimits:        snacRateLimits,
+		convoTracker:          newConvoTracker(),
+		logger:                logger,
+		interval:              rateDecayInterval,
 	}
 }
 
@@ -51,17 +55,19 @@ func NewICBMService(
 // responsible for sending and receiving instant messages and associated
 // functionality such as warning, typing events, etc.
 type ICBMService struct {
-	relationshipFetcher RelationshipFetcher
-	buddyBroadcaster    buddyBroadcaster
-	messageRelayer      MessageRelayer
-	offlineMessageSaver OfflineMessageManager
-	userManager         UserManager
-	timeNow             func() time.Time
-	sessionRetriever    SessionRetriever
-	snacRateLimits      wire.SNACRateLimits
-	convoTracker        *convoTracker
-	logger              *slog.Logger
-	interval            time.Duration
+	relationshipFetcher   RelationshipFetcher
+	buddyBroadcaster      buddyBroadcaster
+	messageRelayer        MessageRelayer
+	offlineMessageSaver   OfflineMessageManager
+	userManager           UserManager
+	feedbagManager        FeedbagManager
+	timeNow               func() time.Time
+	sessionRetriever      SessionRetriever
+	snacRateLimits        wire.SNACRateLimits
+	convoTracker          *convoTracker
+	logger                *slog.Logger
+	interval              time.Duration
+	offlineMessageManager OfflineMessageManager
 }
 
 // ParameterQuery returns ICBM service parameters.
@@ -83,19 +89,6 @@ func (s ICBMService) ParameterQuery(_ context.Context, inFrame wire.SNACFrame) w
 	}
 }
 
-func newICBMErr(requestID uint32, errCode uint16) *wire.SNACMessage {
-	return &wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMErr,
-			RequestID: requestID,
-		},
-		Body: wire.SNACError{
-			Code: errCode,
-		},
-	}
-}
-
 // ChannelMsgToHost relays the instant message SNAC wire.ICBMChannelMsgToHost
 // from the sender to the intended recipient. It returns wire.ICBMHostAck if
 // the wire.ICBMChannelMsgToHost message contains a request acknowledgement
@@ -117,28 +110,24 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 
 	recipSess := s.sessionRetriever.RetrieveSession(recip)
 	if recipSess == nil {
-		// todo: verify user exists, otherwise this could save a bunch of garbage records
-		if _, saveOffline := inBody.Bytes(wire.ICBMTLVStore); saveOffline {
-			offlineMsg := state.OfflineMessage{
-				Message:   inBody,
-				Recipient: recip,
-				Sender:    sess.IdentScreenName(),
-				Sent:      s.timeNow().UTC(),
-			}
-			if err := s.offlineMessageSaver.SaveMessage(ctx, offlineMsg); err != nil {
-				return nil, fmt.Errorf("save ICBM offline message failed: %w", err)
-			}
+		// check for TLV that indicates that the message should be saved offline.
+		// For AIM 6/7, this is only set if the sender has the recipient on
+		// their buddy list and they've seen them online at least once.
+		if _, saveOffline := inBody.Bytes(wire.ICBMTLVStore); !saveOffline {
+			return newICBMErr(inFrame.RequestID, wire.ErrorCodeNotLoggedOn), nil
 		}
-		return &wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeNotLoggedOn,
-			},
-		}, nil
+		canSend, err := s.canSendOfflineMessage(ctx, inBody)
+		if err != nil {
+			return nil, err
+		}
+		if !canSend {
+			return newICBMErr(inFrame.RequestID, wire.ErrorCodeNotLoggedOn), nil
+		}
+		msg, err := s.sendOfflineMessage(ctx, sess, inFrame, inBody)
+		if errors.Is(err, state.ErrNoUser) {
+			return newICBMErr(inFrame.RequestID, wire.ErrorCodeNotLoggedOn), nil
+		}
+		return msg, err
 	}
 
 	clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
@@ -198,6 +187,70 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 	}, nil
 }
 
+// canSendOfflineMessage returns true if the user can send an offline message.
+//
+//	For ICQ users, always return true. Todo: Check ICQ recipient's preferences.
+//
+//	For AIM users, only return false if the recipient has specifically opted out
+//	of receiving offline messages or they do not have a stored buddy list.
+func (s ICBMService) canSendOfflineMessage(ctx context.Context, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (bool, error) {
+	bag, err := s.feedbagManager.Feedbag(ctx, state.NewIdentScreenName(inBody.ScreenName))
+	if err != nil {
+		return false, fmt.Errorf("get feedbag failed: %w", err)
+	}
+
+	for _, item := range bag {
+		if item.ClassID == wire.FeedbagClassIdBuddyPrefs {
+			valid, ok := feedbagBuddyPref(wire.FeedbagBuddyPrefsAcceptOfflineIM, item.TLVList)
+			return !valid || ok, nil
+		}
+	}
+
+	return true, nil
+}
+
+func (s ICBMService) sendOfflineMessage(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	recip := state.NewIdentScreenName(inBody.ScreenName)
+
+	offlineMsg := state.OfflineMessage{
+		Message:   inBody,
+		Recipient: recip,
+		Sender:    sess.IdentScreenName(),
+		Sent:      s.timeNow().UTC(),
+	}
+	if _, err := s.offlineMessageSaver.SaveMessage(ctx, offlineMsg); err != nil {
+		if errors.Is(err, state.ErrOfflineInboxFull) {
+			return newICBMErr(
+				inFrame.RequestID,
+				wire.ErrorCodeNotLoggedOn,
+				wire.NewTLVBE(wire.ErrorTLVErrorSubcode, wire.ICBMSubErrOfflineIMExceedMax),
+			), nil
+		}
+		return nil, fmt.Errorf("save ICBM offline message failed: %w", err)
+	}
+
+	if sess.UIN() > 0 {
+		return newICBMErr(inFrame.RequestID, wire.ErrorCodeNotLoggedOn), nil
+	}
+
+	if _, requestedConfirmation := inBody.TLVRestBlock.Bytes(wire.ICBMTLVRequestHostAck); requestedConfirmation {
+		// ack message back to sender
+		return &wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMHostAck,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+				Cookie:     inBody.Cookie,
+				ChannelID:  inBody.ChannelID,
+				ScreenName: inBody.ScreenName,
+			},
+		}, nil
+	}
+	return nil, nil
+}
+
 // addExternalIP appends the client's IP address to the TLV if it's an ICBM
 // rendezvous proposal/accept message.
 func addExternalIP(sess *state.Session, tlv wire.TLV) (wire.TLV, error) {
@@ -371,6 +424,53 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 	}, nil
 }
 
+func (s ICBMService) OfflineRetrieve(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	msgList, err := s.offlineMessageManager.RetrieveMessages(ctx, sess.IdentScreenName())
+	if err != nil {
+		return wire.SNACMessage{}, fmt.Errorf("retrieving messages: %w", err)
+	}
+
+	for _, event := range msgList {
+		clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			Cookie:    event.Message.Cookie,
+			ChannelID: event.Message.ChannelID,
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName: event.Sender.String(),
+			},
+			TLVRestBlock: wire.TLVRestBlock{},
+		}
+
+		for _, tlv := range event.Message.TLVRestBlock.TLVList {
+			clientIM.Append(tlv)
+		}
+		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVSendTime, uint32(event.Sent.Unix())))
+
+		s.messageRelayer.RelayToScreenName(ctx, event.Recipient, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMChannelMsgToClient,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: clientIM,
+		})
+	}
+
+	if len(msgList) > 0 {
+		if err := s.offlineMessageManager.DeleteMessages(ctx, sess.IdentScreenName()); err != nil {
+			return wire.SNACMessage{}, fmt.Errorf("offlineMessageManager.DeleteMessages: %w", err)
+		}
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMOfflineRetrieveReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x04_0x17_ICBMOfflineRetrieveReply{},
+	}, nil
+}
+
 // RestoreWarningLevel restores the warning level from the last stored value at login time,
 // accounting for time passed between logins.
 func (s ICBMService) RestoreWarningLevel(ctx context.Context, sess *state.Session) error {
@@ -544,6 +644,23 @@ func (s ICBMService) UpdateWarnLevel(ctx context.Context, sess *state.Session) {
 	}
 }
 
+func newICBMErr(requestID uint32, errCode uint16, tlvs ...wire.TLV) *wire.SNACMessage {
+	body := wire.SNACError{
+		Code: errCode,
+	}
+	if len(tlvs) > 0 {
+		body.AppendList(tlvs)
+	}
+	return &wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMErr,
+			RequestID: requestID,
+		},
+		Body: body,
+	}
+}
+
 func calcElapsedWarningLevel(lastWarnUpdate time.Time, now time.Time, interval time.Duration) int16 {
 	// time passed since last signoff
 	since := now.Sub(lastWarnUpdate)

+ 930 - 75
foodgroup/icbm_test.go

@@ -28,6 +28,8 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 		inputSNAC wire.SNACMessage
 		// expectOutput is the expected return SNAC value.
 		expectOutput *wire.SNACMessage
+		// wantErr is the expected error (nil for success)
+		wantErr error
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
@@ -444,7 +446,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 		},
 		{
 			name:          "send offline message to ICQ recipient",
-			senderSession: newTestSession("11111111"),
+			senderSession: newTestSession("11111111", sessOptUIN(11111111)),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -521,6 +523,305 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 						},
 					},
 				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("22222222"),
+							results:    []wire.FeedbagItem{},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "send offline message to recipient with accept offline IM flag set",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMHostAck,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     nil,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{
+						{
+							offlineMessageIn: state.OfflineMessage{
+								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ChannelID:  wire.ICBMChannelIM,
+									ScreenName: "recipient-screen-name",
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+										},
+									},
+								},
+								Recipient: state.NewIdentScreenName("recipient-screen-name"),
+								Sender:    state.NewIdentScreenName("sender-screen-name"),
+								Sent:      time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC),
+							},
+							countOut: 1,
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddyPrefs,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "reject offline message when recipient has opted out of offline messages",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
+				},
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     nil,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddyPrefs,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 1}},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "send offline message when offline IM preference is not valid",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMHostAck,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     nil,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{
+						{
+							offlineMessageIn: state.OfflineMessage{
+								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ChannelID:  wire.ICBMChannelIM,
+									ScreenName: "recipient-screen-name",
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+										},
+									},
+								},
+								Recipient: state.NewIdentScreenName("recipient-screen-name"),
+								Sender:    state.NewIdentScreenName("sender-screen-name"),
+								Sent:      time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC),
+							},
+							countOut: 1,
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddyPrefs,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 7}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
 			},
 		},
 		{
@@ -633,30 +934,339 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				sessionRetrieverParams: sessionRetrieverParams{
 					retrieveSessionParams{
 						{
-							screenName: state.NewIdentScreenName("recipient-screen-name"),
-							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelRendezvous,
+									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+												Type:       wire.ICBMRdvMessageCancel,
+												Capability: wire.CapFileTransfer,
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelRendezvous,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Type:       wire.ICBMRdvMessageCancel,
+								Capability: wire.CapFileTransfer,
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
+		{
+			name:          "send rendezvous request for file transfer without IP in session, expect no IP TLV override",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelRendezvous,
+									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+												Type:       wire.ICBMRdvMessagePropose,
+												Capability: wire.CapFileTransfer,
+												TLVRestBlock: wire.TLVRestBlock{
+													TLVList: wire.TLVList{
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelRendezvous,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Type:       wire.ICBMRdvMessagePropose,
+								Capability: wire.CapFileTransfer,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
+		{
+			name:          "send offline message when inbox is full, return ICBM error with subcode",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ErrorTLVErrorSubcode, wire.ICBMSubErrOfflineIMExceedMax),
+						},
+					},
+				},
+			},
+			wantErr: nil,
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     nil,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{
+						{
+							offlineMessageIn: state.OfflineMessage{
+								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ChannelID:  wire.ICBMChannelIM,
+									ScreenName: "recipient-screen-name",
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+										},
+									},
+								},
+								Recipient: state.NewIdentScreenName("recipient-screen-name"),
+								Sender:    state.NewIdentScreenName("sender-screen-name"),
+								Sent:      time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC),
+							},
+							err: state.ErrOfflineInboxFull,
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddyPrefs,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "send offline message when SaveMessage returns generic error",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			wantErr:      assert.AnError,
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     nil,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{
+						{
+							offlineMessageIn: state.OfflineMessage{
+								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ChannelID:  wire.ICBMChannelIM,
+									ScreenName: "recipient-screen-name",
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
+										},
+									},
+								},
+								Recipient: state.NewIdentScreenName("recipient-screen-name"),
+								Sender:    state.NewIdentScreenName("sender-screen-name"),
+								Sent:      time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC),
+							},
+							err: assert.AnError,
 						},
 					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 							screenName: state.NewIdentScreenName("recipient-screen-name"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.ICBM,
-									SubGroup:  wire.ICBMChannelMsgToClient,
-									RequestID: wire.ReqIDFromServer,
-								},
-								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-									ChannelID:   wire.ICBMChannelRendezvous,
-									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
-									TLVRestBlock: wire.TLVRestBlock{
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddyPrefs,
+									TLVLBlock: wire.TLVLBlock{
 										TLVList: wire.TLVList{
-											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
-												Type:       wire.ICBMRdvMessageCancel,
-												Capability: wire.CapFileTransfer,
-											}),
+											{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
 										},
 									},
 								},
@@ -665,28 +1275,40 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					},
 				},
 			},
+		},
+		{
+			name:          "send offline message when SaveMessage returns ErrNoUser, return ICBM error",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-					ChannelID:  wire.ICBMChannelRendezvous,
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
-								Type:       wire.ICBMRdvMessageCancel,
-								Capability: wire.CapFileTransfer,
-							}),
+							wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
 						},
 					},
 				},
 			},
-			expectOutput: nil,
-		},
-		{
-			name:          "send rendezvous request for file transfer without IP in session, expect no IP TLV override",
-			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
+				},
+			},
+			wantErr: nil,
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
 			mockParams: mockParams{
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
@@ -707,67 +1329,57 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					retrieveSessionParams{
 						{
 							screenName: state.NewIdentScreenName("recipient-screen-name"),
-							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+							result:     nil,
 						},
 					},
 				},
 				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{
 						{
-							screenName: state.NewIdentScreenName("recipient-screen-name"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.ICBM,
-									SubGroup:  wire.ICBMChannelMsgToClient,
-									RequestID: wire.ReqIDFromServer,
-								},
-								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-									ChannelID:   wire.ICBMChannelRendezvous,
-									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
+							offlineMessageIn: state.OfflineMessage{
+								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ChannelID:  wire.ICBMChannelIM,
+									ScreenName: "recipient-screen-name",
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
-											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
-												Type:       wire.ICBMRdvMessagePropose,
-												Capability: wire.CapFileTransfer,
-												TLVRestBlock: wire.TLVRestBlock{
-													TLVList: wire.TLVList{
-														wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
-														wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
-													},
-												},
-											}),
+											wire.NewTLVBE(wire.ICBMTLVRequestHostAck, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3, 4}),
 										},
 									},
 								},
+								Recipient: state.NewIdentScreenName("recipient-screen-name"),
+								Sender:    state.NewIdentScreenName("sender-screen-name"),
+								Sent:      time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC),
 							},
+							err: state.ErrNoUser,
 						},
 					},
 				},
-			},
-			inputSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					RequestID: 1234,
-				},
-				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-					ChannelID:  wire.ICBMChannelRendezvous,
-					ScreenName: "recipient-screen-name",
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
-								Type:       wire.ICBMRdvMessagePropose,
-								Capability: wire.CapFileTransfer,
-								TLVRestBlock: wire.TLVRestBlock{
-									TLVList: wire.TLVList{
-										wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
-										wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddyPrefs,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											{Tag: wire.FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+											{Tag: wire.FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 17}},
+										},
 									},
 								},
-							}),
+							},
 						},
 					},
 				},
 			},
-			expectOutput: nil,
 		},
 	}
 
@@ -794,7 +1406,13 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 			for _, params := range tc.mockParams.saveMessageParams {
 				offlineMessageManager.EXPECT().
 					SaveMessage(matchContext(), params.offlineMessageIn).
-					Return(params.err)
+					Return(params.countOut, params.err)
+			}
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.results, params.err)
 			}
 
 			svc := ICBMService{
@@ -804,11 +1422,12 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				sessionRetriever:    sessionRetriever,
 				timeNow:             tc.timeNow,
 				convoTracker:        newConvoTracker(),
+				feedbagManager:      feedbagManager,
 			}
 
 			outputSNAC, err := svc.ChannelMsgToHost(context.Background(), tc.senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
-			assert.NoError(t, err)
+			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
@@ -1375,7 +1994,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			for _, params := range tc.mockParams.saveMessageParams {
 				offlineMessageManager.EXPECT().
 					SaveMessage(matchContext(), params.offlineMessageIn).
-					Return(params.err)
+					Return(params.countOut, params.err)
 			}
 
 			svc := ICBMService{
@@ -1414,7 +2033,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -1466,12 +2085,248 @@ func TestICBMService_ClientErr(t *testing.T) {
 	messageRelayer.EXPECT().
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 
-	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	err := svc.ClientErr(context.Background(), sess, wire.SNACFrame{RequestID: 1234}, inBody)
 	assert.NoError(t, err)
 }
 
+func TestICBMService_OfflineRetrieve(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// senderSession is the session of the user retrieving messages
+		senderSession *state.Session
+		// inputSNAC is the input frame (RequestID checked on reply)
+		inputSNAC wire.SNACMessage
+		// expectOutput is the expected return SNAC value.
+		expectOutput wire.SNACMessage
+		// wantErr is the expected error (nil for success)
+		wantErr error
+		// mockParams is the list of params sent to mocks that satisfy this method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:          "relays stored messages and replies",
+			senderSession: newTestSession("recipient"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{RequestID: 42},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMOfflineRetrieveReply,
+					RequestID: 42,
+				},
+				Body: wire.SNAC_0x04_0x17_ICBMOfflineRetrieveReply{},
+			},
+			wantErr: nil,
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					retrieveMessagesParams: retrieveMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("recipient"),
+							messagesOut: []state.OfflineMessage{
+								{
+									Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+										Cookie:       1234,
+										ChannelID:    wire.ICBMChannelIM,
+										TLVRestBlock: wire.TLVRestBlock{TLVList: []wire.TLV{wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3})}},
+									},
+									Recipient: state.NewIdentScreenName("recipient"),
+									Sender:    state.NewIdentScreenName("sender"),
+									Sent:      time.Unix(1700000000, 0).UTC(),
+								},
+							},
+						},
+					},
+					deleteMessagesParams: deleteMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("recipient"),
+							err:     nil,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: func() wire.SNAC_0x04_0x07_ICBMChannelMsgToClient {
+									msg := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+										Cookie:    1234,
+										ChannelID: wire.ICBMChannelIM,
+										TLVUserInfo: wire.TLVUserInfo{
+											ScreenName: "sender",
+										},
+										TLVRestBlock: wire.TLVRestBlock{},
+									}
+									msg.Append(wire.NewTLVBE(wire.ICBMTLVData, []byte{1, 2, 3}))
+									msg.Append(wire.NewTLVBE(wire.ICBMTLVSendTime, uint32(time.Unix(1700000000, 0).UTC().Unix())))
+									return msg
+								}(),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "no stored messages returns reply without relays or deletes",
+			senderSession: newTestSession("recipient"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{RequestID: 55},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMOfflineRetrieveReply,
+					RequestID: 55,
+				},
+				Body: wire.SNAC_0x04_0x17_ICBMOfflineRetrieveReply{},
+			},
+			wantErr: nil,
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					retrieveMessagesParams: retrieveMessagesParams{
+						{
+							recipIn:     state.NewIdentScreenName("recipient"),
+							messagesOut: []state.OfflineMessage{},
+							err:         nil,
+						},
+					},
+					deleteMessagesParams: deleteMessagesParams{},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+			},
+		},
+		{
+			name:          "delete messages error after relay",
+			senderSession: newTestSession("recipient"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{RequestID: 99},
+			},
+			expectOutput: wire.SNACMessage{},
+			wantErr:      assert.AnError,
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					retrieveMessagesParams: retrieveMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("recipient"),
+							messagesOut: []state.OfflineMessage{
+								{
+									Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+										Cookie:       4321,
+										ChannelID:    wire.ICBMChannelIM,
+										TLVRestBlock: wire.TLVRestBlock{TLVList: []wire.TLV{wire.NewTLVBE(wire.ICBMTLVData, []byte{9, 8, 7})}},
+									},
+									Recipient: state.NewIdentScreenName("recipient"),
+									Sender:    state.NewIdentScreenName("sender"),
+									Sent:      time.Unix(1700001234, 0).UTC(),
+								},
+							},
+						},
+					},
+					deleteMessagesParams: deleteMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("recipient"),
+							err:     assert.AnError,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: func() wire.SNAC_0x04_0x07_ICBMChannelMsgToClient {
+									msg := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+										Cookie:    4321,
+										ChannelID: wire.ICBMChannelIM,
+										TLVUserInfo: wire.TLVUserInfo{
+											ScreenName: "sender",
+										},
+										TLVRestBlock: wire.TLVRestBlock{},
+									}
+									msg.Append(wire.NewTLVBE(wire.ICBMTLVData, []byte{9, 8, 7}))
+									msg.Append(wire.NewTLVBE(wire.ICBMTLVSendTime, uint32(time.Unix(1700001234, 0).UTC().Unix())))
+									return msg
+								}(),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "propagates retrieve error",
+			senderSession: newTestSession("recipient"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{RequestID: 7},
+			},
+			expectOutput: wire.SNACMessage{},
+			wantErr:      assert.AnError,
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					retrieveMessagesParams: retrieveMessagesParams{
+						{
+							recipIn:     state.NewIdentScreenName("recipient"),
+							messagesOut: nil,
+							err:         assert.AnError,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			offlineMessageManager := newMockOfflineMessageManager(t)
+			for _, params := range tc.mockParams.retrieveMessagesParams {
+				offlineMessageManager.EXPECT().
+					RetrieveMessages(matchContext(), params.recipIn).
+					Return(params.messagesOut, params.err)
+			}
+			for _, params := range tc.mockParams.deleteMessagesParams {
+				offlineMessageManager.EXPECT().
+					DeleteMessages(matchContext(), params.recipIn).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, item := range tc.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, item.screenName, item.message)
+			}
+
+			svc := ICBMService{
+				messageRelayer:        messageRelayer,
+				offlineMessageManager: offlineMessageManager,
+			}
+
+			out, err := svc.OfflineRetrieve(context.Background(), tc.senderSession, tc.inputSNAC.Frame)
+			assert.Equal(t, tc.expectOutput, out)
+			assert.ErrorIs(t, err, tc.wantErr)
+		})
+	}
+}
+
 func TestRingBuffer(t *testing.T) {
 	t.Run("new ringBuffer should have zero values", func(t *testing.T) {
 		rb := &ringBuffer{}

+ 65 - 7
foodgroup/mock_offline_message_manager_test.go

@@ -129,21 +129,31 @@ func (_c *mockOfflineMessageManager_RetrieveMessages_Call) RunAndReturn(run func
 }
 
 // SaveMessage provides a mock function with given fields: ctx, offlineMessage
-func (_m *mockOfflineMessageManager) SaveMessage(ctx context.Context, offlineMessage state.OfflineMessage) error {
+func (_m *mockOfflineMessageManager) SaveMessage(ctx context.Context, offlineMessage state.OfflineMessage) (int, error) {
 	ret := _m.Called(ctx, offlineMessage)
 
 	if len(ret) == 0 {
 		panic("no return value specified for SaveMessage")
 	}
 
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, state.OfflineMessage) error); ok {
+	var r0 int
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.OfflineMessage) (int, error)); ok {
+		return rf(ctx, offlineMessage)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, state.OfflineMessage) int); ok {
 		r0 = rf(ctx, offlineMessage)
 	} else {
-		r0 = ret.Error(0)
+		r0 = ret.Get(0).(int)
 	}
 
-	return r0
+	if rf, ok := ret.Get(1).(func(context.Context, state.OfflineMessage) error); ok {
+		r1 = rf(ctx, offlineMessage)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
 }
 
 // mockOfflineMessageManager_SaveMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveMessage'
@@ -165,12 +175,60 @@ func (_c *mockOfflineMessageManager_SaveMessage_Call) Run(run func(ctx context.C
 	return _c
 }
 
-func (_c *mockOfflineMessageManager_SaveMessage_Call) Return(_a0 error) *mockOfflineMessageManager_SaveMessage_Call {
+func (_c *mockOfflineMessageManager_SaveMessage_Call) Return(_a0 int, _a1 error) *mockOfflineMessageManager_SaveMessage_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_SaveMessage_Call) RunAndReturn(run func(context.Context, state.OfflineMessage) (int, error)) *mockOfflineMessageManager_SaveMessage_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetOfflineMsgCount provides a mock function with given fields: ctx, screenName, count
+func (_m *mockOfflineMessageManager) SetOfflineMsgCount(ctx context.Context, screenName state.IdentScreenName, count int) error {
+	ret := _m.Called(ctx, screenName, count)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetOfflineMsgCount")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, int) error); ok {
+		r0 = rf(ctx, screenName, count)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockOfflineMessageManager_SetOfflineMsgCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOfflineMsgCount'
+type mockOfflineMessageManager_SetOfflineMsgCount_Call struct {
+	*mock.Call
+}
+
+// SetOfflineMsgCount is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - count int
+func (_e *mockOfflineMessageManager_Expecter) SetOfflineMsgCount(ctx interface{}, screenName interface{}, count interface{}) *mockOfflineMessageManager_SetOfflineMsgCount_Call {
+	return &mockOfflineMessageManager_SetOfflineMsgCount_Call{Call: _e.mock.On("SetOfflineMsgCount", ctx, screenName, count)}
+}
+
+func (_c *mockOfflineMessageManager_SetOfflineMsgCount_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, count int)) *mockOfflineMessageManager_SetOfflineMsgCount_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(int))
+	})
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_SetOfflineMsgCount_Call) Return(_a0 error) *mockOfflineMessageManager_SetOfflineMsgCount_Call {
 	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockOfflineMessageManager_SaveMessage_Call) RunAndReturn(run func(context.Context, state.OfflineMessage) error) *mockOfflineMessageManager_SaveMessage_Call {
+func (_c *mockOfflineMessageManager_SetOfflineMsgCount_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, int) error) *mockOfflineMessageManager_SetOfflineMsgCount_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 62 - 15
foodgroup/oservice.go

@@ -22,11 +22,12 @@ type OServiceService struct {
 	snacRateLimits   wire.SNACRateLimits
 	timeNow          func() time.Time
 
-	chatRoomManager    ChatRoomRegistry
-	cookieIssuer       CookieBaker
-	messageRelayer     MessageRelayer
-	chatMessageRelayer ChatMessageRelayer
-	profileManager     ProfileManager
+	chatRoomManager       ChatRoomRegistry
+	cookieIssuer          CookieBaker
+	messageRelayer        MessageRelayer
+	chatMessageRelayer    ChatMessageRelayer
+	profileManager        ProfileManager
+	offlineMessageManager OfflineMessageManager
 }
 
 // NewOServiceService creates a new instance of NewOServiceService.
@@ -42,18 +43,20 @@ func NewOServiceService(
 	snacRateLimits wire.SNACRateLimits,
 	chatMessageRelayer ChatMessageRelayer,
 	profileManager ProfileManager,
+	offlineMessageManager OfflineMessageManager,
 ) *OServiceService {
 	return &OServiceService{
-		cookieIssuer:       cookieIssuer,
-		messageRelayer:     messageRelayer,
-		buddyBroadcaster:   newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		cfg:                cfg,
-		logger:             logger,
-		snacRateLimits:     snacRateLimits,
-		timeNow:            time.Now,
-		chatRoomManager:    chatRoomManager,
-		chatMessageRelayer: chatMessageRelayer,
-		profileManager:     profileManager,
+		cookieIssuer:          cookieIssuer,
+		messageRelayer:        messageRelayer,
+		buddyBroadcaster:      newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		cfg:                   cfg,
+		logger:                logger,
+		snacRateLimits:        snacRateLimits,
+		timeNow:               time.Now,
+		chatRoomManager:       chatRoomManager,
+		chatMessageRelayer:    chatMessageRelayer,
+		profileManager:        profileManager,
+		offlineMessageManager: offlineMessageManager,
 	}
 }
 
@@ -681,6 +684,11 @@ func (s OServiceService) ClientOnline(ctx context.Context, service uint16, bodyI
 			}
 		}
 
+		if sess.OfflineMsgCount() > 0 {
+			if err := s.sendOfflineMessageNotification(ctx, sess); err != nil {
+				return fmt.Errorf("send offline message notification: %w", err)
+			}
+		}
 	case wire.Chat:
 		room, err := s.chatRoomManager.ChatRoomByCookie(ctx, sess.ChatRoomCookie())
 		if err != nil {
@@ -699,6 +707,45 @@ func (s OServiceService) ClientOnline(ctx context.Context, service uint16, bodyI
 	return nil
 }
 
+// sendOfflineMessageNotification sends an IM notifying the user of their
+// offline message count and resets the count to zero.
+func (s OServiceService) sendOfflineMessageNotification(ctx context.Context, sess *state.Session) error {
+	msg := fmt.Sprintf("You just received %d IM(s) while you were offline. If you do "+
+		"not wish to receive offline messages, please go to "+
+		"<a href=\"http://settings.aim.com/?loc=en-zz\">IM Settings</a>.", sess.OfflineMsgCount())
+	frags, err := wire.ICBMFragmentList(msg)
+	if err != nil {
+		return fmt.Errorf("creating ICBM fragments: %w", err)
+	}
+
+	s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMChannelMsgToClient,
+			RequestID: wire.ReqIDFromServer,
+		},
+		Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			ChannelID: wire.ICBMChannelIM,
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName: "AOL System Msg",
+			},
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: []wire.TLV{
+					wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags),
+				},
+			},
+		},
+	})
+
+	//todo: do the following in a doOnce()
+	sess.SetOfflineMsgCount(0)
+	if err := s.offlineMessageManager.SetOfflineMsgCount(ctx, sess.IdentScreenName(), 0); err != nil {
+		return fmt.Errorf("deleting offline messages: %w", err)
+	}
+
+	return nil
+}
+
 // newOServiceUserInfoUpdate constructs SNAC(0x01,0x0F) for user info updates.
 // For OService version 4 and above, it appends a duplicate TLVUserInfo block.
 // AIM 6+ expects at least two user info blocks to support multi-session:

+ 190 - 5
foodgroup/oservice_test.go

@@ -814,7 +814,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, nil)
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, nil, nil)
 
 			outputSNAC, err := svc.ServiceRequest(context.Background(), tc.service, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest), tc.listener)
@@ -1161,7 +1161,7 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMSinStored},
 				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMSinListQuery},
 				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMSinListReply},
-				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMSinRetrieve},
+				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMOfflineRetrieve},
 				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMSinDelete},
 				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMNotifyRequest},
 				{FoodGroup: wire.ICBM, SubGroup: wire.ICBMNotifyReply},
@@ -1703,7 +1703,7 @@ func TestOServiceService_HostOnline(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil, nil)
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil, nil, nil)
 			have := svc.HostOnline(tc.service)
 			assert.Equal(t, tc.expectOutput, have)
 		})
@@ -2107,6 +2107,184 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 				),
 			),
 		},
+		{
+			name:    "notify that BOS user is online with 0 offline messages, no notification sent",
+			sess:    newTestSession("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(0)),
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.BOS,
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("me"),
+							filter:           nil,
+							doSendDepartures: false,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+					},
+				},
+			},
+			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete, sessOptOfflineMsgCount(0)),
+		},
+		{
+			name:    "notify that BOS user is online with offline messages, send notification and reset count",
+			sess:    newTestSession("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(3)),
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.BOS,
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("me"),
+							filter:           nil,
+							doSendDepartures: false,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: func() wire.SNACMessage {
+								msg := "You just received 3 IM(s) while you were offline. If you do " +
+									"not wish to receive offline messages, please go to " +
+									"<a href=\"http://settings.aim.com/?loc=en-zz\">IM Settings</a>."
+								frags, _ := wire.ICBMFragmentList(msg)
+								return wire.SNACMessage{
+									Frame: wire.SNACFrame{
+										FoodGroup: wire.ICBM,
+										SubGroup:  wire.ICBMChannelMsgToClient,
+										RequestID: wire.ReqIDFromServer,
+									},
+									Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+										ChannelID: wire.ICBMChannelIM,
+										TLVUserInfo: wire.TLVUserInfo{
+											ScreenName: "AOL System Msg",
+										},
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: []wire.TLV{
+												wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags),
+											},
+										},
+									},
+								}
+							}(),
+						},
+					},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					setOfflineMsgCountParams: setOfflineMsgCountParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							count:      0,
+							err:        nil,
+						},
+					},
+				},
+			},
+			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete, sessOptOfflineMsgCount(0)),
+		},
+		{
+			name:    "notify that BOS user is online with offline messages, SetOfflineMsgCount fails",
+			sess:    newTestSession("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(2)),
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.BOS,
+			wantErr: assert.AnError,
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("me"),
+							filter:           nil,
+							doSendDepartures: false,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: func() wire.SNACMessage {
+								msg := "You just received 2 IM(s) while you were offline. If you do " +
+									"not wish to receive offline messages, please go to " +
+									"<a href=\"http://settings.aim.com/?loc=en-zz\">IM Settings</a>."
+								frags, _ := wire.ICBMFragmentList(msg)
+								return wire.SNACMessage{
+									Frame: wire.SNACFrame{
+										FoodGroup: wire.ICBM,
+										SubGroup:  wire.ICBMChannelMsgToClient,
+										RequestID: wire.ReqIDFromServer,
+									},
+									Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+										ChannelID: wire.ICBMChannelIM,
+										TLVUserInfo: wire.TLVUserInfo{
+											ScreenName: "AOL System Msg",
+										},
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: []wire.TLV{
+												wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags),
+											},
+										},
+									},
+								}
+							}(),
+						},
+					},
+				},
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					setOfflineMsgCountParams: setOfflineMsgCountParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							count:      0,
+							err:        assert.AnError,
+						},
+					},
+				},
+			},
+			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete, sessOptOfflineMsgCount(0)),
+		},
 		{
 			name:    "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
 			sess:    chatter1,
@@ -2229,13 +2407,20 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 					Profile(mock.Anything, params.screenName).
 					Return(params.result, params.err)
 			}
+			offlineMessageManager := newMockOfflineMessageManager(t)
+			for _, params := range tt.mockParams.offlineMessageManagerParams.setOfflineMsgCountParams {
+				offlineMessageManager.EXPECT().
+					SetOfflineMsgCount(matchContext(), params.screenName, params.count).
+					Return(params.err)
+			}
 
-			svc := NewOServiceService(config.Config{}, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, profileManager)
+			svc := NewOServiceService(config.Config{}, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, profileManager, offlineMessageManager)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			haveErr := svc.ClientOnline(context.Background(), tt.service, tt.bodyIn, tt.sess)
-			assert.ErrorIs(t, tt.wantErr, haveErr)
+			assert.ErrorIs(t, haveErr, tt.wantErr)
 			assert.Equal(t, tt.wantSess.SignonComplete(), tt.sess.SignonComplete())
 			assert.Equal(t, tt.wantSess.Profile(), tt.sess.Profile())
+			assert.Equal(t, tt.wantSess.OfflineMsgCount(), tt.sess.OfflineMsgCount())
 		})
 	}
 }

+ 5 - 2
foodgroup/types.go

@@ -302,8 +302,11 @@ type OfflineMessageManager interface {
 	// RetrieveMessages returns all offline messages for the specified recipient.
 	RetrieveMessages(ctx context.Context, recip state.IdentScreenName) ([]state.OfflineMessage, error)
 
-	// SaveMessage stores a new offline message for delivery when the recipient comes online.
-	SaveMessage(ctx context.Context, offlineMessage state.OfflineMessage) error
+	// SaveMessage stores a new offline message for delivery when the recipient comes online and returns the sender's total queued message count for that recipient.
+	SaveMessage(ctx context.Context, offlineMessage state.OfflineMessage) (int, error)
+
+	// SetOfflineMsgCount sets the offline message count for a user.
+	SetOfflineMsgCount(ctx context.Context, screenName state.IdentScreenName, count int) error
 }
 
 // ProfileManager defines methods for managing and querying AIM user profiles,

+ 11 - 0
server/oscar/handler.go

@@ -394,6 +394,15 @@ func (rt Handler) ICBMClientEvent(ctx context.Context, sess *state.Session, inFr
 	return rt.ICBMService.ClientEvent(ctx, sess, inFrame, inBody)
 }
 
+func (rt Handler) ICBMOfflineRetrieve(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, rw ResponseWriter) error {
+	outSNAC, err := rt.ICBMService.OfflineRetrieve(ctx, sess, inFrame)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
 func (rt Handler) ICQDBQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
 	inBody := wire.SNAC_0x15_0x02_BQuery{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
@@ -1024,6 +1033,8 @@ func (rt Handler) Handle(ctx context.Context, server uint16, sess *state.Session
 			return rt.ICBMEvilRequest(ctx, sess, inFrame, r, rw)
 		case wire.ICBMParameterQuery:
 			return rt.ICBMParameterQuery(ctx, sess, inFrame, r, rw)
+		case wire.ICBMOfflineRetrieve:
+			return rt.ICBMOfflineRetrieve(ctx, sess, inFrame, rw)
 		}
 	case wire.Locate:
 		switch inFrame.SubGroup {

+ 70 - 0
server/oscar/handler_test.go

@@ -2407,6 +2407,76 @@ func TestHandler_ICBMEvilRequest(t *testing.T) {
 	}
 }
 
+func TestHandler_ICBMOfflineRetrieve(t *testing.T) {
+	tests := []struct {
+		name          string
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+		},
+		{
+			name:          "service error",
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name:          "response writer error",
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMOfflineRetrieve,
+				},
+				Body: wire.SNAC_0x04_0x0A_ICBMOfflineRetrieve{},
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMOfflineRetrieveReply,
+				},
+				Body: wire.SNAC_0x04_0x17_ICBMOfflineRetrieveReply{},
+			}
+
+			svc := newMockICBMService(t)
+			svc.EXPECT().
+				OfflineRetrieve(mock.Anything, mock.Anything, input.Frame).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
 func TestHandler_ICBMParameterQuery(t *testing.T) {
 	tests := []struct {
 		name          string

+ 58 - 0
server/oscar/mock_icbm_service_test.go

@@ -242,6 +242,64 @@ func (_c *mockICBMService_EvilRequest_Call) RunAndReturn(run func(context.Contex
 	return _c
 }
 
+// OfflineRetrieve provides a mock function with given fields: ctx, sess, inFrame
+func (_m *mockICBMService) OfflineRetrieve(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for OfflineRetrieve")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame) error); ok {
+		r1 = rf(ctx, sess, inFrame)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICBMService_OfflineRetrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OfflineRetrieve'
+type mockICBMService_OfflineRetrieve_Call struct {
+	*mock.Call
+}
+
+// OfflineRetrieve is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+func (_e *mockICBMService_Expecter) OfflineRetrieve(ctx interface{}, sess interface{}, inFrame interface{}) *mockICBMService_OfflineRetrieve_Call {
+	return &mockICBMService_OfflineRetrieve_Call{Call: _e.mock.On("OfflineRetrieve", ctx, sess, inFrame)}
+}
+
+func (_c *mockICBMService_OfflineRetrieve_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame)) *mockICBMService_OfflineRetrieve_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_OfflineRetrieve_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockICBMService_OfflineRetrieve_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICBMService_OfflineRetrieve_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame) (wire.SNACMessage, error)) *mockICBMService_OfflineRetrieve_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // ParameterQuery provides a mock function with given fields: ctx, inFrame
 func (_m *mockICBMService) ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
 	ret := _m.Called(ctx, inFrame)

+ 2 - 1
server/oscar/types.go

@@ -100,10 +100,11 @@ type FeedbagService interface {
 
 type ICBMService interface {
 	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	ClientErr(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr) error
 	ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
 	EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
+	OfflineRetrieve(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	ClientErr(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr) error
 	RestoreWarningLevel(ctx context.Context, sess *state.Session) error
 	UpdateWarnLevel(ctx context.Context, sess *state.Session)
 }

+ 13 - 3
server/webapi/handlers/messaging.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -23,7 +24,7 @@ type MessageRelayer interface {
 
 // OfflineMessageManager defines methods for managing offline messages
 type OfflineMessageManager interface {
-	SaveMessage(ctx context.Context, msg state.OfflineMessage) error
+	SaveMessage(ctx context.Context, msg state.OfflineMessage) (int, error)
 }
 
 // RelationshipFetcher defines methods for fetching user relationships
@@ -163,7 +164,15 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 				Sent:      time.Now().UTC(),
 			}
 
-			if err := h.OfflineMessageManager.SaveMessage(ctx, offlineMsg); err != nil {
+			count, err := h.OfflineMessageManager.SaveMessage(ctx, offlineMsg)
+			if err != nil {
+				if errors.Is(err, state.ErrOfflineInboxFull) {
+					h.Logger.WarnContext(ctx, "offline inbox full",
+						"from", sess.ScreenName.String(),
+						"to", recipient)
+					h.sendErrorResponse(w, http.StatusConflict, "recipient inbox full")
+					return
+				}
 				h.Logger.ErrorContext(ctx, "failed to save offline message",
 					"from", sess.ScreenName.String(),
 					"to", recipient,
@@ -174,7 +183,8 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 
 			h.Logger.DebugContext(ctx, "saved offline message",
 				"from", sess.ScreenName.String(),
-				"to", recipient)
+				"to", recipient,
+				"count", count)
 		} else {
 			// Recipient is offline and offline delivery is disabled
 			h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")

+ 1 - 1
server/webapi/types.go

@@ -135,7 +135,7 @@ type MessageRelayer interface {
 
 // OfflineMessageManager manages offline message storage and retrieval
 type OfflineMessageManager interface {
-	SaveMessage(ctx context.Context, msg state.OfflineMessage) error
+	SaveMessage(ctx context.Context, msg state.OfflineMessage) (int, error)
 	RetrieveMessages(ctx context.Context, recipient state.IdentScreenName) ([]state.OfflineMessage, error)
 	DeleteMessages(ctx context.Context, recipient state.IdentScreenName) error
 }

+ 22 - 0
state/migrations/0026_offline_message_fk_index.down.sql

@@ -0,0 +1,22 @@
+ALTER TABLE users
+    DROP COLUMN offlineMsgCount;
+
+DROP INDEX IF EXISTS idx_offlineMessage_sender;
+DROP INDEX IF EXISTS idx_offlineMessage_recipient;
+
+ALTER TABLE offlineMessage
+    RENAME TO offlineMessage_new;
+
+CREATE TABLE offlineMessage
+(
+    sender    VARCHAR(16) NOT NULL,
+    recipient VARCHAR(16) NOT NULL,
+    message   BLOB        NOT NULL,
+    sent      TIMESTAMP   NOT NULL
+);
+
+INSERT INTO offlineMessage (sender, recipient, message, sent)
+SELECT sender, recipient, message, sent
+FROM offlineMessage_new;
+
+DROP TABLE offlineMessage_new;

+ 24 - 0
state/migrations/0026_offline_message_fk_index.up.sql

@@ -0,0 +1,24 @@
+ALTER TABLE offlineMessage
+    RENAME TO offlineMessage_old;
+
+CREATE TABLE offlineMessage
+(
+    sender    VARCHAR(16) NOT NULL,
+    recipient VARCHAR(16) NOT NULL,
+    message   BLOB        NOT NULL,
+    sent      TIMESTAMP   NOT NULL,
+    FOREIGN KEY (sender) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE,
+    FOREIGN KEY (recipient) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+INSERT INTO offlineMessage (sender, recipient, message, sent)
+SELECT sender, recipient, message, sent
+FROM offlineMessage_old;
+
+DROP TABLE offlineMessage_old;
+
+CREATE INDEX idx_offlineMessage_sender ON offlineMessage (sender);
+CREATE INDEX idx_offlineMessage_recipient ON offlineMessage (recipient);
+
+ALTER TABLE users
+    ADD COLUMN offlineMsgCount INTEGER NOT NULL DEFAULT 0;

+ 15 - 0
state/session.go

@@ -81,6 +81,7 @@ type Session struct {
 	lastWarnUpdate          time.Time
 	profile                 UserProfile
 	memberSince             time.Time
+	offlineMsgCount         int
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
@@ -734,3 +735,17 @@ func (s *Session) MemberSince() time.Time {
 	defer s.mutex.RUnlock()
 	return s.memberSince
 }
+
+// SetOfflineMsgCount sets the offline message count.
+func (s *Session) SetOfflineMsgCount(count int) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.offlineMsgCount = count
+}
+
+// OfflineMsgCount returns the offline message count.
+func (s *Session) OfflineMsgCount() int {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.offlineMsgCount
+}

+ 13 - 0
state/session_test.go

@@ -685,6 +685,19 @@ func TestSession_SetAndGetMemberSince(t *testing.T) {
 	assert.Equal(t, memberTime, s.MemberSince())
 }
 
+func TestSession_SetAndGetOfflineMsgCount(t *testing.T) {
+	s := NewSession()
+	assert.Zero(t, s.OfflineMsgCount())
+
+	count := 5
+	s.SetOfflineMsgCount(count)
+	assert.Equal(t, count, s.OfflineMsgCount())
+
+	count = 10
+	s.SetOfflineMsgCount(count)
+	assert.Equal(t, count, s.OfflineMsgCount())
+}
+
 func TestSession_ScaleWarningAndRateLimit(t *testing.T) {
 	t.Run("scale up", func(t *testing.T) {
 		classParams := [5]wire.RateClass{

+ 2 - 0
state/user.go

@@ -206,6 +206,8 @@ type User struct {
 	LastWarnUpdate time.Time
 	// LastWarnLevel is the warning level when the user last signed off.
 	LastWarnLevel uint16
+	// OfflineMsgCount is the count of offline messages for the user.
+	OfflineMsgCount int
 }
 
 // UserProfile represents a user's profile information.

+ 93 - 5
state/user_store.go

@@ -25,6 +25,8 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
+const offlineInboxLimit = 10
+
 var (
 	ErrKeywordCategoryExists   = errors.New("keyword category already exists")
 	ErrKeywordCategoryNotFound = errors.New("keyword category not found")
@@ -33,6 +35,7 @@ var (
 	ErrKeywordExists           = errors.New("keyword already exists")
 	ErrKeywordInUse            = errors.New("can't delete keyword that is associated with a user")
 	ErrKeywordNotFound         = errors.New("keyword not found")
+	ErrOfflineInboxFull        = errors.New("offline inbox full")
 	errTooManyCategories       = errors.New("there are too many keyword categories")
 	errTooManyKeywords         = errors.New("there are too many keywords")
 )
@@ -415,7 +418,8 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			aim_address,
 			tocConfig,
 			lastWarnUpdate,
-			lastWarnLevel
+			lastWarnLevel,
+			offlineMsgCount
 		FROM users
 		WHERE %s
 	`
@@ -512,6 +516,7 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			&u.TOCConfig,
 			&lastWarnUpdateUnix,
 			&u.LastWarnLevel,
+			&u.OfflineMsgCount,
 		)
 		if err != nil {
 			return nil, err
@@ -1604,24 +1609,82 @@ func (f SQLiteUserStore) SetBasicInfo(ctx context.Context, name IdentScreenName,
 	return nil
 }
 
-func (f SQLiteUserStore) SaveMessage(ctx context.Context, offlineMessage OfflineMessage) error {
+func (f SQLiteUserStore) SaveMessage(ctx context.Context, offlineMessage OfflineMessage) (newCount int, err error) {
 	buf := &bytes.Buffer{}
 	if err := wire.MarshalBE(offlineMessage.Message, buf); err != nil {
-		return fmt.Errorf("marshal: %w", err)
+		return 0, fmt.Errorf("marshal: %w", err)
+	}
+
+	var tx *sql.Tx
+	tx, err = f.db.BeginTx(ctx, nil)
+	if err != nil {
+		return 0, fmt.Errorf("begin tx: %w", err)
+	}
+	defer func() {
+		if err != nil {
+			_ = tx.Rollback()
+		}
+	}()
+
+	const countQuery = `
+		SELECT COUNT(1)
+		FROM offlineMessage
+		WHERE sender = ? AND recipient = ?
+	`
+	var currentCount int
+	if err = tx.QueryRowContext(
+		ctx,
+		countQuery,
+		offlineMessage.Sender.String(),
+		offlineMessage.Recipient.String(),
+	).Scan(&currentCount); err != nil {
+		return 0, fmt.Errorf("count: %w", err)
+	}
+
+	if currentCount >= offlineInboxLimit {
+		err = ErrOfflineInboxFull
+		return 0, err
 	}
 
 	q := `
 		INSERT INTO offlineMessage (sender, recipient, message, sent)
 		VALUES (?, ?, ?, ?)
 	`
-	_, err := f.db.ExecContext(ctx,
+	if _, err = tx.ExecContext(ctx,
 		q,
 		offlineMessage.Sender.String(),
 		offlineMessage.Recipient.String(),
 		buf.Bytes(),
 		offlineMessage.Sent,
+	); err != nil {
+		if sqliteErr, ok := err.(*sqlite.Error); ok && sqliteErr.Code() == lib.SQLITE_CONSTRAINT_FOREIGNKEY {
+			err = ErrNoUser
+		} else {
+			err = fmt.Errorf("insert: %w", err)
+		}
+		return 0, err
+	}
+
+	newCount = currentCount + 1
+	updateQuery := `
+		UPDATE users
+		SET offlineMsgCount = ?
+		WHERE identScreenName = ?
+	`
+	_, err = tx.ExecContext(ctx,
+		updateQuery,
+		newCount,
+		offlineMessage.Recipient.String(),
 	)
-	return err
+	if err != nil {
+		return 0, fmt.Errorf("update offlineMsgCount: %w", err)
+	}
+
+	if err = tx.Commit(); err != nil {
+		return 0, fmt.Errorf("commit: %w", err)
+	}
+
+	return newCount, nil
 }
 
 func (f SQLiteUserStore) RetrieveMessages(ctx context.Context, recip IdentScreenName) ([]OfflineMessage, error) {
@@ -2074,3 +2137,28 @@ func (f SQLiteUserStore) SetWarnLevel(ctx context.Context, user IdentScreenName,
 	}
 	return nil
 }
+
+// SetOfflineMsgCount updates the offline message count for a user.
+func (f SQLiteUserStore) SetOfflineMsgCount(ctx context.Context, screenName IdentScreenName, count int) error {
+	q := `
+		UPDATE users
+		SET offlineMsgCount = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.ExecContext(ctx,
+		q,
+		count,
+		screenName.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}

+ 139 - 4
state/user_store_test.go

@@ -420,6 +420,34 @@ func TestGetUser(t *testing.T) {
 	}
 }
 
+func TestSQLiteUserStore_User_OfflineMsgCount(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	screenName := NewIdentScreenName("testuser")
+
+	// Insert user first
+	err = f.InsertUser(context.Background(), User{
+		IdentScreenName:   screenName,
+		DisplayScreenName: DisplayScreenName("testuser"),
+	})
+	assert.NoError(t, err)
+
+	// Set offlineMsgCount using the store method
+	err = f.SetOfflineMsgCount(context.Background(), screenName, 5)
+	assert.NoError(t, err)
+
+	// Retrieve user and verify OfflineMsgCount is loaded
+	user, err := f.User(context.Background(), screenName)
+	assert.NoError(t, err)
+	assert.NotNil(t, user)
+	assert.Equal(t, 5, user.OfflineMsgCount)
+}
+
 func TestGetUserNotFound(t *testing.T) {
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))
@@ -1992,6 +2020,17 @@ func TestSQLiteUserStore_RetrieveMessages(t *testing.T) {
 	f, err := NewSQLiteUserStore(testFile)
 	assert.NoError(t, err)
 
+	createStubUser := func(t *testing.T, store SQLiteUserStore, screenName DisplayScreenName) {
+		t.Helper()
+		user, err := NewStubUser(screenName)
+		require.NoError(t, err)
+		require.NoError(t, store.InsertUser(context.Background(), user))
+	}
+
+	createStubUser(t, *f, DisplayScreenName("John"))
+	createStubUser(t, *f, DisplayScreenName("Jack"))
+	createStubUser(t, *f, DisplayScreenName("Anne"))
+
 	sendTime := time.Now().UTC()
 
 	offlineMessages := []OfflineMessage{
@@ -2021,9 +2060,17 @@ func TestSQLiteUserStore_RetrieveMessages(t *testing.T) {
 		},
 	}
 
-	for _, msg := range offlineMessages {
-		err = f.SaveMessage(context.Background(), msg)
+	expectedCounts := []int{1, 1, 2}
+	for i, msg := range offlineMessages {
+		count, err := f.SaveMessage(context.Background(), msg)
+		assert.NoError(t, err)
+		assert.Equal(t, expectedCounts[i], count)
+
+		// Verify offlineMsgCount is updated in the database
+		recipient, err := f.User(context.Background(), msg.Recipient)
 		assert.NoError(t, err)
+		assert.NotNil(t, recipient)
+		assert.Equal(t, expectedCounts[i], recipient.OfflineMsgCount)
 	}
 
 	t.Run("Retrieve Messages", func(t *testing.T) {
@@ -2050,6 +2097,17 @@ func TestSQLiteUserStore_DeleteMessages(t *testing.T) {
 	f, err := NewSQLiteUserStore(testFile)
 	assert.NoError(t, err)
 
+	createStubUser := func(t *testing.T, store SQLiteUserStore, screenName DisplayScreenName) {
+		t.Helper()
+		user, err := NewStubUser(screenName)
+		require.NoError(t, err)
+		require.NoError(t, store.InsertUser(context.Background(), user))
+	}
+
+	createStubUser(t, *f, DisplayScreenName("John"))
+	createStubUser(t, *f, DisplayScreenName("Jack"))
+	createStubUser(t, *f, DisplayScreenName("Anne"))
+
 	sendTime := time.Now().UTC()
 
 	offlineMessages := []OfflineMessage{
@@ -2079,9 +2137,17 @@ func TestSQLiteUserStore_DeleteMessages(t *testing.T) {
 		},
 	}
 
-	for _, msg := range offlineMessages {
-		err = f.SaveMessage(context.Background(), msg)
+	expectedCounts := []int{1, 1, 2}
+	for i, msg := range offlineMessages {
+		count, err := f.SaveMessage(context.Background(), msg)
+		assert.NoError(t, err)
+		assert.Equal(t, expectedCounts[i], count)
+
+		// Verify offlineMsgCount is updated in the database
+		recipient, err := f.User(context.Background(), msg.Recipient)
 		assert.NoError(t, err)
+		assert.NotNil(t, recipient)
+		assert.Equal(t, expectedCounts[i], recipient.OfflineMsgCount)
 	}
 
 	t.Run("Delete Messages", func(t *testing.T) {
@@ -2107,6 +2173,74 @@ func TestSQLiteUserStore_DeleteMessages(t *testing.T) {
 	})
 }
 
+func TestSQLiteUserStore_SaveMessage(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	store, err := NewSQLiteUserStore(testFile)
+	require.NoError(t, err)
+
+	createStubUser := func(t *testing.T, store SQLiteUserStore, screenName DisplayScreenName) {
+		t.Helper()
+		user, err := NewStubUser(screenName)
+		require.NoError(t, err)
+		require.NoError(t, store.InsertUser(context.Background(), user))
+	}
+	createStubUser(t, *store, DisplayScreenName("Sender"))
+	createStubUser(t, *store, DisplayScreenName("Recipient"))
+
+	msg := OfflineMessage{
+		Sender:    NewIdentScreenName("Sender"),
+		Recipient: NewIdentScreenName("Recipient"),
+		Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+			Cookie: 42,
+		},
+		Sent: time.Now().UTC(),
+	}
+
+	t.Run("within limit", func(t *testing.T) {
+		for i := 1; i <= offlineInboxLimit; i++ {
+			count, err := store.SaveMessage(context.Background(), msg)
+			require.NoError(t, err)
+			require.Equal(t, i, count)
+
+			// Verify offlineMsgCount is updated in the database
+			recipient, err := store.User(context.Background(), msg.Recipient)
+			require.NoError(t, err)
+			require.NotNil(t, recipient)
+			require.Equal(t, i, recipient.OfflineMsgCount)
+		}
+	})
+
+	t.Run("limit exceeded", func(t *testing.T) {
+		_, err := store.SaveMessage(context.Background(), msg)
+		require.ErrorIs(t, err, ErrOfflineInboxFull)
+	})
+
+	t.Run("missing sender", func(t *testing.T) {
+		missingSenderMsg := OfflineMessage{
+			Sender:    NewIdentScreenName("UnknownSender"),
+			Recipient: NewIdentScreenName("Recipient"),
+			Message:   msg.Message,
+			Sent:      time.Now().UTC(),
+		}
+		_, err := store.SaveMessage(context.Background(), missingSenderMsg)
+		require.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("missing recipient", func(t *testing.T) {
+		missingRecipientMsg := OfflineMessage{
+			Sender:    NewIdentScreenName("Sender"),
+			Recipient: NewIdentScreenName("UnknownRecipient"),
+			Message:   msg.Message,
+			Sent:      time.Now().UTC(),
+		}
+		_, err := store.SaveMessage(context.Background(), missingRecipientMsg)
+		require.ErrorIs(t, err, ErrNoUser)
+	})
+}
+
 func TestSQLiteUserStore_BuddyIconMetadataExistingRef(t *testing.T) {
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))
@@ -2875,6 +3009,7 @@ func TestSQLiteUserStore_UnregisterBuddyList(t *testing.T) {
 	assert.NoError(t, err)
 
 	relationships, err = f.AllRelationships(context.Background(), users[0], nil)
+	assert.NoError(t, err)
 	expect = []Relationship{
 		{
 			User:          NewIdentScreenName("user2"),

+ 1 - 1
wire/rate_limit.go

@@ -217,7 +217,7 @@ func DefaultSNACRateLimits() SNACRateLimits {
 				ICBMSinStored:          1,
 				ICBMSinListQuery:       1,
 				ICBMSinListReply:       1,
-				ICBMSinRetrieve:        1,
+				ICBMOfflineRetrieve:    1,
 				ICBMSinDelete:          1,
 				ICBMNotifyRequest:      1,
 				ICBMNotifyReply:        1,

+ 46 - 21
wire/snacs.go

@@ -78,6 +78,12 @@ const (
 	ErrorCodeInFreeArea           uint16 = 0x1E
 	ErrorCodeRestrictedByPc       uint16 = 0x1F
 	ErrorCodeRemoteRestrictedByPc uint16 = 0x20
+
+	ErrorTLVFailURL        uint16 = 0x0004 // URL with more detail
+	ErrorTLVErrorSubcode   uint16 = 0x0008 // Foodgroup-specific error code
+	ErrorTLVErrorText      uint16 = 0x001B // String error message text
+	ErrorTLVErrorInfoCLSID uint16 = 0x0029 // UUID specifying format of ERROR_INFO_DATA data
+	ErrorTLVErrorInfoData  uint16 = 0x002A // Extra information describing error
 )
 
 //
@@ -556,27 +562,27 @@ type SNAC_0x03_0x0C_BuddyDeparted struct {
 //
 
 const (
-	ICBMErr                uint16 = 0x0001
-	ICBMAddParameters      uint16 = 0x0002
-	ICBMDelParameters      uint16 = 0x0003
-	ICBMParameterQuery     uint16 = 0x0004
-	ICBMParameterReply     uint16 = 0x0005
-	ICBMChannelMsgToHost   uint16 = 0x0006
-	ICBMChannelMsgToClient uint16 = 0x0007
-	ICBMEvilRequest        uint16 = 0x0008
-	ICBMEvilReply          uint16 = 0x0009
-	ICBMMissedCalls        uint16 = 0x000A
-	ICBMClientErr          uint16 = 0x000B
-	ICBMHostAck            uint16 = 0x000C
-	ICBMSinStored          uint16 = 0x000D
-	ICBMSinListQuery       uint16 = 0x000E
-	ICBMSinListReply       uint16 = 0x000F
-	ICBMSinRetrieve        uint16 = 0x0010
-	ICBMSinDelete          uint16 = 0x0011
-	ICBMNotifyRequest      uint16 = 0x0012
-	ICBMNotifyReply        uint16 = 0x0013
-	ICBMClientEvent        uint16 = 0x0014
-	ICBMSinReply           uint16 = 0x0017
+	ICBMErr                  uint16 = 0x0001
+	ICBMAddParameters        uint16 = 0x0002
+	ICBMDelParameters        uint16 = 0x0003
+	ICBMParameterQuery       uint16 = 0x0004
+	ICBMParameterReply       uint16 = 0x0005
+	ICBMChannelMsgToHost     uint16 = 0x0006
+	ICBMChannelMsgToClient   uint16 = 0x0007
+	ICBMEvilRequest          uint16 = 0x0008
+	ICBMEvilReply            uint16 = 0x0009
+	ICBMMissedCalls          uint16 = 0x000A
+	ICBMClientErr            uint16 = 0x000B
+	ICBMHostAck              uint16 = 0x000C
+	ICBMSinStored            uint16 = 0x000D
+	ICBMSinListQuery         uint16 = 0x000E
+	ICBMSinListReply         uint16 = 0x000F
+	ICBMOfflineRetrieve      uint16 = 0x0010
+	ICBMSinDelete            uint16 = 0x0011
+	ICBMNotifyRequest        uint16 = 0x0012
+	ICBMNotifyReply          uint16 = 0x0013
+	ICBMClientEvent          uint16 = 0x0014
+	ICBMOfflineRetrieveReply uint16 = 0x0017
 
 	ICBMTLVAOLIMData      uint16 = 0x02
 	ICBMTLVRequestHostAck uint16 = 0x03
@@ -681,6 +687,20 @@ const (
 	ICBMRdvTLVTagsSessID              uint16 = 0x0019 // string	Identifier for session
 	ICBMRdvTLVTagsRolloverID          uint16 = 0x001A // string	Identifier of session to rollover
 	ICBMRdvTLVTagsSvcData             uint16 = 0x2711 //	blob	Service specific data
+
+	ICBMSubErrRemoteIMOff            uint16 = 0x0001 // Used with NOT_LOGGED_ON
+	ICBMSubErrRemoteRestrictedByPC   uint16 = 0x0002 // Used with NOT_LOGGED_ON; the remote side denied because of parental controls
+	ICBMSubErrNeedSMSLegalToSend     uint16 = 0x0003 // User tried to send a message to an SMS user and is required to accept the legal text first
+	ICBMSubErrSMSWithoutDisclaimer   uint16 = 0x0004 // Client tried to send a message to an SMS user without the character counter being displayed
+	ICBMSubErrSMSCountryNotAllowed   uint16 = 0x0005 // Client tried to send a message to an SMS user but the SMS matrix said the country code combination not permitted
+	ICBMSubErrSMSUnknownCountry      uint16 = 0x0008 // Client tried to send to an SMS user but the server could not determine the country
+	ICBMSubErrCannotInitiateIM       uint16 = 0x0009 // An IM cannot be initiated by a BOT
+	ICBMSubErrIMNotAllowed           uint16 = 0x000A // An IM is not allowed by a consumer BOT to a user
+	ICBMSubErrCannotIMUsageLimited   uint16 = 0x000B // An IM is not allowed by a consumer BOT due to reaching a generic usage limit
+	ICBMSubErrCannotIMDailyLimited   uint16 = 0x000C // An IM is not allowed by a consumer BOT due to reaching the daily usage limit
+	ICBMSubErrCannotIMMonthlyLimited uint16 = 0x000D // An IM is not allowed by a consumer BOT due to reaching the monthly usage limit
+	ICBMSubErrOfflineIMNotAccepted   uint16 = 0x000E // User does not accept offline IMs
+	ICBMSubErrOfflineIMExceedMax     uint16 = 0x000F // Exceeded max storage limit
 )
 
 // ICBMCh1Fragment represents an ICBM channel 1 (instant message) message
@@ -832,6 +852,10 @@ type SNAC_0x04_0x14_ICBMClientEvent struct {
 	Event      uint16
 }
 
+type SNAC_0x04_0x0A_ICBMOfflineRetrieve struct{}
+
+type SNAC_0x04_0x17_ICBMOfflineRetrieveReply struct{}
+
 //
 // 0x05: Advert
 //
@@ -1546,6 +1570,7 @@ const (
 	FeedbagAttributesPdModeXc                uint16 = 0x016E
 
 	FeedbagBuddyPrefsWantsTypingEvents uint32 = 0x400000 // user wants to send and receive typing events
+	FeedbagBuddyPrefsAcceptOfflineIM   uint16 = 0x34     // user wants to send and receive typing events
 
 	FeedbagRightsMaxClassAttrs       uint16 = 0x02
 	FeedbagRightsMaxItemAttrs        uint16 = 0x03

+ 21 - 21
wire/snacs_string.go

@@ -115,27 +115,27 @@ var subGroupName = map[uint16]map[uint16]string{
 		BuddyDelTempBuddies:      "BuddyDelTempBuddies",
 	},
 	ICBM: {
-		ICBMErr:                "ICBMErr",
-		ICBMAddParameters:      "ICBMAddParameters",
-		ICBMDelParameters:      "ICBMDelParameters",
-		ICBMParameterQuery:     "ICBMParameterQuery",
-		ICBMParameterReply:     "ICBMParameterReply",
-		ICBMChannelMsgToHost:   "ICBMChannelMsgToHost",
-		ICBMChannelMsgToClient: "ICBMChannelMsgToClient",
-		ICBMEvilRequest:        "ICBMEvilRequest",
-		ICBMEvilReply:          "ICBMEvilReply",
-		ICBMMissedCalls:        "ICBMMissedCalls",
-		ICBMClientErr:          "ICBMClientErr",
-		ICBMHostAck:            "ICBMHostAck",
-		ICBMSinStored:          "ICBMSinStored",
-		ICBMSinListQuery:       "ICBMSinListQuery",
-		ICBMSinListReply:       "ICBMSinListReply",
-		ICBMSinRetrieve:        "ICBMSinRetrieve",
-		ICBMSinDelete:          "ICBMSinDelete",
-		ICBMNotifyRequest:      "ICBMNotifyRequest",
-		ICBMNotifyReply:        "ICBMNotifyReply",
-		ICBMClientEvent:        "ICBMClientEvent",
-		ICBMSinReply:           "ICBMSinReply",
+		ICBMErr:                  "ICBMErr",
+		ICBMAddParameters:        "ICBMAddParameters",
+		ICBMDelParameters:        "ICBMDelParameters",
+		ICBMParameterQuery:       "ICBMParameterQuery",
+		ICBMParameterReply:       "ICBMParameterReply",
+		ICBMChannelMsgToHost:     "ICBMChannelMsgToHost",
+		ICBMChannelMsgToClient:   "ICBMChannelMsgToClient",
+		ICBMEvilRequest:          "ICBMEvilRequest",
+		ICBMEvilReply:            "ICBMEvilReply",
+		ICBMMissedCalls:          "ICBMMissedCalls",
+		ICBMClientErr:            "ICBMClientErr",
+		ICBMHostAck:              "ICBMHostAck",
+		ICBMSinStored:            "ICBMSinStored",
+		ICBMSinListQuery:         "ICBMSinListQuery",
+		ICBMSinListReply:         "ICBMSinListReply",
+		ICBMOfflineRetrieve:      "ICBMOfflineRetrieve",
+		ICBMSinDelete:            "ICBMSinDelete",
+		ICBMNotifyRequest:        "ICBMNotifyRequest",
+		ICBMNotifyReply:          "ICBMNotifyReply",
+		ICBMClientEvent:          "ICBMClientEvent",
+		ICBMOfflineRetrieveReply: "ICBMOfflineRetrieveReply",
 	},
 	ChatNav: {
 		ChatNavErr:                 "ChatNavErr",