mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-11-04 04:22:24 -06:00 
			
		
		
		
	* start working on notifs for new posts * tidy up a bit * update swagger * carry over show reblogs + notify from follow req * test notify on status post * update column slice * dedupe update logic + add tests * fix own boosts not being timelined * avoid type check, passing unnecessary accounts * remove unnecessary 'inReplyToID' check * add a couple todo's for future db functions
		
			
				
	
	
		
			503 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			503 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
// GoToSocial
 | 
						|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
 | 
						|
// SPDX-License-Identifier: AGPL-3.0-or-later
 | 
						|
//
 | 
						|
// This program is free software: you can redistribute it and/or modify
 | 
						|
// it under the terms of the GNU Affero General Public License as published by
 | 
						|
// the Free Software Foundation, either version 3 of the License, or
 | 
						|
// (at your option) any later version.
 | 
						|
//
 | 
						|
// This program is distributed in the hope that it will be useful,
 | 
						|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
						|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
						|
// GNU Affero General Public License for more details.
 | 
						|
//
 | 
						|
// You should have received a copy of the GNU Affero General Public License
 | 
						|
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
						|
 | 
						|
package processing
 | 
						|
 | 
						|
import (
 | 
						|
	"context"
 | 
						|
	"errors"
 | 
						|
	"fmt"
 | 
						|
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/config"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/db"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/email"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/id"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/stream"
 | 
						|
)
 | 
						|
 | 
						|
// timelineAndNotifyStatus processes the given new status and inserts it into
 | 
						|
// the HOME timelines of accounts that follow the status author. It will also
 | 
						|
// handle notifications for any mentions attached to the account, and also
 | 
						|
// notifications for any local accounts that want a notif when this account posts.
 | 
						|
func (p *Processor) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.Status) error {
 | 
						|
	// Ensure status fully populated; including account, mentions, etc.
 | 
						|
	if err := p.state.DB.PopulateStatus(ctx, status); err != nil {
 | 
						|
		return fmt.Errorf("timelineAndNotifyStatus: error populating status with id %s: %w", status.ID, err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Get local followers of the account that posted the status.
 | 
						|
	follows, err := p.state.DB.GetAccountLocalFollowers(ctx, status.AccountID)
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("timelineAndNotifyStatus: error getting local followers for account id %s: %w", status.AccountID, err)
 | 
						|
	}
 | 
						|
 | 
						|
	// If the poster is also local, add a fake entry for them
 | 
						|
	// so they can see their own status in their timeline.
 | 
						|
	if status.Account.IsLocal() {
 | 
						|
		follows = append(follows, >smodel.Follow{
 | 
						|
			AccountID:   status.AccountID,
 | 
						|
			Account:     status.Account,
 | 
						|
			Notify:      func() *bool { b := false; return &b }(), // Account shouldn't notify itself.
 | 
						|
			ShowReblogs: func() *bool { b := true; return &b }(),  // Account should show own reblogs.
 | 
						|
		})
 | 
						|
	}
 | 
						|
 | 
						|
	// Timeline the status for each local follower of this account.
 | 
						|
	// This will also handle notifying any followers with notify
 | 
						|
	// set to true on their follow.
 | 
						|
	if err := p.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil {
 | 
						|
		return fmt.Errorf("timelineAndNotifyStatus: error timelining status %s for followers: %w", status.ID, err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Notify each local account that's mentioned by this status.
 | 
						|
	if err := p.notifyStatusMentions(ctx, status); err != nil {
 | 
						|
		return fmt.Errorf("timelineAndNotifyStatus: error notifying status mentions for status %s: %w", status.ID, err)
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) timelineAndNotifyStatusForFollowers(ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow) error {
 | 
						|
	var (
 | 
						|
		errs  = make(gtserror.MultiError, 0, len(follows))
 | 
						|
		boost = status.BoostOfID != ""
 | 
						|
		reply = status.InReplyToURI != ""
 | 
						|
	)
 | 
						|
 | 
						|
	for _, follow := range follows {
 | 
						|
		if sr := follow.ShowReblogs; boost && (sr == nil || !*sr) {
 | 
						|
			// This is a boost, but this follower
 | 
						|
			// doesn't want to see those from this
 | 
						|
			// account, so just skip everything.
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		// Add status to home timeline for this
 | 
						|
		// follower, and stream it if applicable.
 | 
						|
		if timelined, err := p.timelineStatusForAccount(ctx, follow.Account, status); err != nil {
 | 
						|
			errs.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error timelining status: %w", err))
 | 
						|
			continue
 | 
						|
		} else if !timelined {
 | 
						|
			// Status wasn't added to home tomeline,
 | 
						|
			// so we shouldn't notify it either.
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		if n := follow.Notify; n == nil || !*n {
 | 
						|
			// This follower doesn't have notifications
 | 
						|
			// set for this account's new posts, so bail.
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		if boost || reply {
 | 
						|
			// Don't notify for boosts or replies.
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		// If we reach here, we know:
 | 
						|
		//
 | 
						|
		//   - This follower wants to be notified when this account posts.
 | 
						|
		//   - This is a top-level post (not a reply).
 | 
						|
		//   - This is not a boost of another post.
 | 
						|
		//   - The post is visible in this follower's home timeline.
 | 
						|
		//
 | 
						|
		// That means we can officially notify this one.
 | 
						|
		if err := p.notify(
 | 
						|
			ctx,
 | 
						|
			gtsmodel.NotificationStatus,
 | 
						|
			follow.AccountID,
 | 
						|
			status.AccountID,
 | 
						|
			status.ID,
 | 
						|
		); err != nil {
 | 
						|
			errs.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error notifying account %s about new status: %w", follow.AccountID, err))
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return errs.Combine()
 | 
						|
}
 | 
						|
 | 
						|
// timelineStatusForAccount puts the given status in the HOME timeline
 | 
						|
// of the account with given accountID, if it's HomeTimelineable.
 | 
						|
//
 | 
						|
// If the status was inserted into the home timeline of the given account,
 | 
						|
// true will be returned + it will also be streamed via websockets to the user.
 | 
						|
func (p *Processor) timelineStatusForAccount(ctx context.Context, account *gtsmodel.Account, status *gtsmodel.Status) (bool, error) {
 | 
						|
	// Make sure the status is timelineable.
 | 
						|
	if timelineable, err := p.filter.StatusHomeTimelineable(ctx, account, status); err != nil {
 | 
						|
		err = fmt.Errorf("timelineStatusForAccount: error getting timelineability for status for timeline with id %s: %w", account.ID, err)
 | 
						|
		return false, err
 | 
						|
	} else if !timelineable {
 | 
						|
		// Nothing to do.
 | 
						|
		return false, nil
 | 
						|
	}
 | 
						|
 | 
						|
	// Insert status in the home timeline of account.
 | 
						|
	if inserted, err := p.statusTimelines.IngestOne(ctx, account.ID, status); err != nil {
 | 
						|
		err = fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %w", status.ID, err)
 | 
						|
		return false, err
 | 
						|
	} else if !inserted {
 | 
						|
		// Nothing more to do.
 | 
						|
		return false, nil
 | 
						|
	}
 | 
						|
 | 
						|
	// The status was inserted so stream it to the user.
 | 
						|
	apiStatus, err := p.tc.StatusToAPIStatus(ctx, status, account)
 | 
						|
	if err != nil {
 | 
						|
		err = fmt.Errorf("timelineStatusForAccount: error converting status %s to frontend representation: %w", status.ID, err)
 | 
						|
		return true, err
 | 
						|
	}
 | 
						|
 | 
						|
	if err := p.stream.Update(apiStatus, account, stream.TimelineHome); err != nil {
 | 
						|
		err = fmt.Errorf("timelineStatusForAccount: error streaming update for status %s: %w", status.ID, err)
 | 
						|
		return true, err
 | 
						|
	}
 | 
						|
 | 
						|
	return true, nil
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) notifyStatusMentions(ctx context.Context, status *gtsmodel.Status) error {
 | 
						|
	errs := make(gtserror.MultiError, 0, len(status.Mentions))
 | 
						|
 | 
						|
	for _, m := range status.Mentions {
 | 
						|
		if err := p.notify(
 | 
						|
			ctx,
 | 
						|
			gtsmodel.NotificationMention,
 | 
						|
			m.TargetAccountID,
 | 
						|
			m.OriginAccountID,
 | 
						|
			m.StatusID,
 | 
						|
		); err != nil {
 | 
						|
			errs.Append(err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return errs.Combine()
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) notifyFollowRequest(ctx context.Context, followRequest *gtsmodel.FollowRequest) error {
 | 
						|
	return p.notify(
 | 
						|
		ctx,
 | 
						|
		gtsmodel.NotificationFollowRequest,
 | 
						|
		followRequest.TargetAccountID,
 | 
						|
		followRequest.AccountID,
 | 
						|
		"",
 | 
						|
	)
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) notifyFollow(ctx context.Context, follow *gtsmodel.Follow, targetAccount *gtsmodel.Account) error {
 | 
						|
	// Remove previous follow request notification, if it exists.
 | 
						|
	prevNotif, err := p.state.DB.GetNotification(
 | 
						|
		gtscontext.SetBarebones(ctx),
 | 
						|
		gtsmodel.NotificationFollowRequest,
 | 
						|
		targetAccount.ID,
 | 
						|
		follow.AccountID,
 | 
						|
		"",
 | 
						|
	)
 | 
						|
	if err != nil && !errors.Is(err, db.ErrNoEntries) {
 | 
						|
		// Proper error while checking.
 | 
						|
		return fmt.Errorf("notifyFollow: db error checking for previous follow request notification: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	if prevNotif != nil {
 | 
						|
		// Previous notification existed, delete.
 | 
						|
		if err := p.state.DB.DeleteNotificationByID(ctx, prevNotif.ID); err != nil {
 | 
						|
			return fmt.Errorf("notifyFollow: db error removing previous follow request notification %s: %w", prevNotif.ID, err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// Now notify the follow itself.
 | 
						|
	return p.notify(
 | 
						|
		ctx,
 | 
						|
		gtsmodel.NotificationFollow,
 | 
						|
		targetAccount.ID,
 | 
						|
		follow.AccountID,
 | 
						|
		"",
 | 
						|
	)
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) notifyFave(ctx context.Context, fave *gtsmodel.StatusFave) error {
 | 
						|
	if fave.TargetAccountID == fave.AccountID {
 | 
						|
		// Self-fave, nothing to do.
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	return p.notify(
 | 
						|
		ctx,
 | 
						|
		gtsmodel.NotificationFave,
 | 
						|
		fave.TargetAccountID,
 | 
						|
		fave.AccountID,
 | 
						|
		fave.StatusID,
 | 
						|
	)
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) notifyAnnounce(ctx context.Context, status *gtsmodel.Status) error {
 | 
						|
	if status.BoostOfID == "" {
 | 
						|
		// Not a boost, nothing to do.
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	if status.BoostOfAccountID == status.AccountID {
 | 
						|
		// Self-boost, nothing to do.
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	return p.notify(
 | 
						|
		ctx,
 | 
						|
		gtsmodel.NotificationReblog,
 | 
						|
		status.BoostOfAccountID,
 | 
						|
		status.AccountID,
 | 
						|
		status.ID,
 | 
						|
	)
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) notify(
 | 
						|
	ctx context.Context,
 | 
						|
	notificationType gtsmodel.NotificationType,
 | 
						|
	targetAccountID string,
 | 
						|
	originAccountID string,
 | 
						|
	statusID string,
 | 
						|
) error {
 | 
						|
	targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("notify: error getting target account %s: %w", targetAccountID, err)
 | 
						|
	}
 | 
						|
 | 
						|
	if !targetAccount.IsLocal() {
 | 
						|
		// Nothing to do.
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	// Make sure a notification doesn't
 | 
						|
	// already exist with these params.
 | 
						|
	if _, err := p.state.DB.GetNotification(
 | 
						|
		ctx,
 | 
						|
		notificationType,
 | 
						|
		targetAccountID,
 | 
						|
		originAccountID,
 | 
						|
		statusID,
 | 
						|
	); err == nil {
 | 
						|
		// Notification exists, nothing to do.
 | 
						|
		return nil
 | 
						|
	} else if !errors.Is(err, db.ErrNoEntries) {
 | 
						|
		// Real error.
 | 
						|
		return fmt.Errorf("notify: error checking existence of notification: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Notification doesn't yet exist, so
 | 
						|
	// we need to create + store one.
 | 
						|
	notif := >smodel.Notification{
 | 
						|
		ID:               id.NewULID(),
 | 
						|
		NotificationType: notificationType,
 | 
						|
		TargetAccountID:  targetAccountID,
 | 
						|
		OriginAccountID:  originAccountID,
 | 
						|
		StatusID:         statusID,
 | 
						|
	}
 | 
						|
 | 
						|
	if err := p.state.DB.PutNotification(ctx, notif); err != nil {
 | 
						|
		return fmt.Errorf("notify: error putting notification in database: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Stream notification to the user.
 | 
						|
	apiNotif, err := p.tc.NotificationToAPINotification(ctx, notif)
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("notify: error converting notification to api representation: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	if err := p.stream.Notify(apiNotif, targetAccount); err != nil {
 | 
						|
		return fmt.Errorf("notify: error streaming notification to account: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
// wipeStatus contains common logic used to totally delete a status
 | 
						|
// + all its attachments, notifications, boosts, and timeline entries.
 | 
						|
func (p *Processor) wipeStatus(ctx context.Context, statusToDelete *gtsmodel.Status, deleteAttachments bool) error {
 | 
						|
	// either delete all attachments for this status, or simply
 | 
						|
	// unattach all attachments for this status, so they'll be
 | 
						|
	// cleaned later by a separate process; reason to unattach rather
 | 
						|
	// than delete is that the poster might want to reattach them
 | 
						|
	// to another status immediately (in case of delete + redraft)
 | 
						|
	if deleteAttachments {
 | 
						|
		// todo: p.state.DB.DeleteAttachmentsForStatus
 | 
						|
		for _, a := range statusToDelete.AttachmentIDs {
 | 
						|
			if err := p.media.Delete(ctx, a); err != nil {
 | 
						|
				return err
 | 
						|
			}
 | 
						|
		}
 | 
						|
	} else {
 | 
						|
		// todo: p.state.DB.UnattachAttachmentsForStatus
 | 
						|
		for _, a := range statusToDelete.AttachmentIDs {
 | 
						|
			if _, err := p.media.Unattach(ctx, statusToDelete.Account, a); err != nil {
 | 
						|
				return err
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// delete all mention entries generated by this status
 | 
						|
	// todo: p.state.DB.DeleteMentionsForStatus
 | 
						|
	for _, id := range statusToDelete.MentionIDs {
 | 
						|
		if err := p.state.DB.DeleteMentionByID(ctx, id); err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// delete all notification entries generated by this status
 | 
						|
	if err := p.state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// delete all bookmarks that point to this status
 | 
						|
	if err := p.state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// delete all faves of this status
 | 
						|
	if err := p.state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// delete all boosts for this status + remove them from timelines
 | 
						|
	if boosts, err := p.state.DB.GetStatusReblogs(ctx, statusToDelete); err == nil {
 | 
						|
		for _, b := range boosts {
 | 
						|
			if err := p.deleteStatusFromTimelines(ctx, b); err != nil {
 | 
						|
				return err
 | 
						|
			}
 | 
						|
			if err := p.state.DB.DeleteStatusByID(ctx, b.ID); err != nil {
 | 
						|
				return err
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// delete this status from any and all timelines
 | 
						|
	if err := p.deleteStatusFromTimelines(ctx, statusToDelete); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// delete the status itself
 | 
						|
	if err := p.state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
// deleteStatusFromTimelines completely removes the given status from all timelines.
 | 
						|
// It will also stream deletion of the status to all open streams.
 | 
						|
func (p *Processor) deleteStatusFromTimelines(ctx context.Context, status *gtsmodel.Status) error {
 | 
						|
	if err := p.statusTimelines.WipeItemFromAllTimelines(ctx, status.ID); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	return p.stream.Delete(status.ID)
 | 
						|
}
 | 
						|
 | 
						|
/*
 | 
						|
	EMAIL FUNCTIONS
 | 
						|
*/
 | 
						|
 | 
						|
func (p *Processor) emailReport(ctx context.Context, report *gtsmodel.Report) error {
 | 
						|
	instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("emailReport: error getting instance: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	toAddresses, err := p.state.DB.GetInstanceModeratorAddresses(ctx)
 | 
						|
	if err != nil {
 | 
						|
		if errors.Is(err, db.ErrNoEntries) {
 | 
						|
			// No registered moderator addresses.
 | 
						|
			return nil
 | 
						|
		}
 | 
						|
		return fmt.Errorf("emailReport: error getting instance moderator addresses: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	if report.Account == nil {
 | 
						|
		report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID)
 | 
						|
		if err != nil {
 | 
						|
			return fmt.Errorf("emailReport: error getting report account: %w", err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if report.TargetAccount == nil {
 | 
						|
		report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID)
 | 
						|
		if err != nil {
 | 
						|
			return fmt.Errorf("emailReport: error getting report target account: %w", err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	reportData := email.NewReportData{
 | 
						|
		InstanceURL:        instance.URI,
 | 
						|
		InstanceName:       instance.Title,
 | 
						|
		ReportURL:          instance.URI + "/settings/admin/reports/" + report.ID,
 | 
						|
		ReportDomain:       report.Account.Domain,
 | 
						|
		ReportTargetDomain: report.TargetAccount.Domain,
 | 
						|
	}
 | 
						|
 | 
						|
	if err := p.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil {
 | 
						|
		return fmt.Errorf("emailReport: error emailing instance moderators: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) emailReportClosed(ctx context.Context, report *gtsmodel.Report) error {
 | 
						|
	user, err := p.state.DB.GetUserByAccountID(ctx, report.Account.ID)
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("emailReportClosed: db error getting user: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	if user.ConfirmedAt.IsZero() || !*user.Approved || *user.Disabled || user.Email == "" {
 | 
						|
		// Only email users who:
 | 
						|
		// - are confirmed
 | 
						|
		// - are approved
 | 
						|
		// - are not disabled
 | 
						|
		// - have an email address
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("emailReportClosed: db error getting instance: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	if report.Account == nil {
 | 
						|
		report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID)
 | 
						|
		if err != nil {
 | 
						|
			return fmt.Errorf("emailReportClosed: error getting report account: %w", err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if report.TargetAccount == nil {
 | 
						|
		report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID)
 | 
						|
		if err != nil {
 | 
						|
			return fmt.Errorf("emailReportClosed: error getting report target account: %w", err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	reportClosedData := email.ReportClosedData{
 | 
						|
		Username:             report.Account.Username,
 | 
						|
		InstanceURL:          instance.URI,
 | 
						|
		InstanceName:         instance.Title,
 | 
						|
		ReportTargetUsername: report.TargetAccount.Username,
 | 
						|
		ReportTargetDomain:   report.TargetAccount.Domain,
 | 
						|
		ActionTakenComment:   report.ActionTaken,
 | 
						|
	}
 | 
						|
 | 
						|
	return p.emailSender.SendReportClosedEmail(user.Email, reportClosedData)
 | 
						|
}
 |