Now that graph errors are always transformed as part of the graph client wrapper or http_wrapper, we don't need to call graph.Stack or graph.Wrap outside of those inner helpers any longer. This PR swaps all those graph calls with equivalent clues funcs as a cleanup. Should not contain any logical changes. --- #### Does this PR need a docs update or release note? - [x] ⛔ No #### Type of change - [x] 🧹 Tech Debt/Cleanup #### Issue(s) * #4685 #### Test Plan - [x] ⚡ Unit test - [x] 💚 E2E
698 lines
18 KiB
Go
698 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/alcionai/clues"
|
|
"github.com/microsoft/kiota-abstractions-go/serialization"
|
|
kjson "github.com/microsoft/kiota-serialization-json-go"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/users"
|
|
|
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
|
"github.com/alcionai/corso/src/internal/common/sanitize"
|
|
"github.com/alcionai/corso/src/pkg/backup/details"
|
|
"github.com/alcionai/corso/src/pkg/dttm"
|
|
"github.com/alcionai/corso/src/pkg/errs/core"
|
|
"github.com/alcionai/corso/src/pkg/fault"
|
|
"github.com/alcionai/corso/src/pkg/logger"
|
|
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
|
)
|
|
|
|
const (
|
|
mailFoldersBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/mailFolders"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (c Client) Mail() Mail {
|
|
return Mail{c}
|
|
}
|
|
|
|
// Mail is an interface-compliant provider of the client.
|
|
type Mail struct {
|
|
Client
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// containers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (c Mail) CreateContainer(
|
|
ctx context.Context,
|
|
userID, parentContainerID, containerName string,
|
|
) (graph.Container, error) {
|
|
isHidden := false
|
|
body := models.NewMailFolder()
|
|
body.SetDisplayName(&containerName)
|
|
body.SetIsHidden(&isHidden)
|
|
|
|
mdl, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(parentContainerID).
|
|
ChildFolders().
|
|
Post(ctx, body, nil)
|
|
|
|
return mdl, clues.Wrap(err, "creating nested mail folder").OrNil()
|
|
}
|
|
|
|
// DeleteContainer removes a mail folder with the corresponding M365 ID from the user's M365 Exchange account
|
|
// Reference: https://docs.microsoft.com/en-us/graph/api/mailfolder-delete?view=graph-rest-1.0&tabs=http
|
|
func (c Mail) DeleteContainer(
|
|
ctx context.Context,
|
|
userID, containerID string,
|
|
) error {
|
|
// deletes require unique http clients
|
|
// https://github.com/alcionai/corso/issues/2707
|
|
srv, err := NewService(c.Credentials, c.counter)
|
|
if err != nil {
|
|
return clues.StackWC(ctx, err)
|
|
}
|
|
|
|
err = srv.Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Delete(ctx, nil)
|
|
|
|
return clues.Stack(err).OrNil()
|
|
}
|
|
|
|
func (c Mail) GetContainerByID(
|
|
ctx context.Context,
|
|
userID, containerID string,
|
|
) (graph.Container, error) {
|
|
config := &users.ItemMailFoldersMailFolderItemRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMailFoldersMailFolderItemRequestBuilderGetQueryParameters{
|
|
Select: idAnd(displayName, parentFolderID),
|
|
},
|
|
}
|
|
|
|
resp, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Get(ctx, config)
|
|
|
|
return resp, clues.Stack(err).OrNil()
|
|
}
|
|
|
|
// GetContainerByName fetches a folder by name
|
|
func (c Mail) GetContainerByName(
|
|
ctx context.Context,
|
|
userID, parentContainerID, containerName string,
|
|
) (graph.Container, error) {
|
|
filter := fmt.Sprintf("displayName eq '%s'", containerName)
|
|
|
|
ctx = clues.Add(ctx, "container_name", containerName)
|
|
|
|
var (
|
|
builder = c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders()
|
|
resp models.MailFolderCollectionResponseable
|
|
err error
|
|
)
|
|
|
|
if len(parentContainerID) > 0 {
|
|
options := &users.ItemMailFoldersItemChildFoldersRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMailFoldersItemChildFoldersRequestBuilderGetQueryParameters{
|
|
Filter: &filter,
|
|
},
|
|
}
|
|
|
|
resp, err = builder.
|
|
ByMailFolderId(parentContainerID).
|
|
ChildFolders().
|
|
Get(ctx, options)
|
|
} else {
|
|
options := &users.ItemMailFoldersRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMailFoldersRequestBuilderGetQueryParameters{
|
|
Filter: &filter,
|
|
},
|
|
}
|
|
|
|
resp, err = builder.Get(ctx, options)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, clues.Stack(err)
|
|
}
|
|
|
|
gv := resp.GetValue()
|
|
|
|
if len(gv) == 0 {
|
|
return nil, clues.NewWC(ctx, "container not found")
|
|
}
|
|
|
|
// We only allow the api to match one container with the provided name.
|
|
// Return an error if multiple container exist (unlikely) or if no container
|
|
// is found.
|
|
if len(gv) != 1 {
|
|
return nil, clues.StackWC(ctx, core.ErrMultipleResultsMatchIdentifier).
|
|
With("returned_container_count", len(gv))
|
|
}
|
|
|
|
// Sanity check ID and name
|
|
container := gv[0]
|
|
|
|
if err := graph.CheckIDAndName(container); err != nil {
|
|
return nil, clues.StackWC(ctx, err)
|
|
}
|
|
|
|
return container, nil
|
|
}
|
|
|
|
func (c Mail) MoveContainer(
|
|
ctx context.Context,
|
|
userID, containerID string,
|
|
body users.ItemMailFoldersItemMovePostRequestBodyable,
|
|
) error {
|
|
_, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Move().
|
|
Post(ctx, body, nil)
|
|
|
|
return clues.Wrap(err, "moving mail folder").OrNil()
|
|
}
|
|
|
|
func (c Mail) PatchFolder(
|
|
ctx context.Context,
|
|
userID, containerID string,
|
|
body models.MailFolderable,
|
|
) error {
|
|
_, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Patch(ctx, body, nil)
|
|
|
|
return clues.Wrap(err, "patching mail folder").OrNil()
|
|
}
|
|
|
|
// TODO: needs pager implementation for completion
|
|
func (c Mail) GetContainerChildren(
|
|
ctx context.Context,
|
|
userID, containerID string,
|
|
) ([]models.MailFolderable, error) {
|
|
resp, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
ChildFolders().
|
|
Get(ctx, nil)
|
|
if err != nil {
|
|
return nil, clues.Wrap(err, "getting container child folders")
|
|
}
|
|
|
|
return resp.GetValue(), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// items
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetItem retrieves a Messageable item. If the item contains an attachment, that
|
|
// attachment is also downloaded.
|
|
func (c Mail) GetItem(
|
|
ctx context.Context,
|
|
userID, mailID string,
|
|
errs *fault.Bus,
|
|
) (serialization.Parsable, *details.ExchangeInfo, error) {
|
|
var (
|
|
// ends up as len(mail.Body) + sum([]attachment.size)
|
|
size int64
|
|
mailBody models.ItemBodyable
|
|
config = &users.ItemMessagesMessageItemRequestBuilderGetRequestConfiguration{
|
|
Headers: newPreferHeaders(preferImmutableIDs(c.options.ToggleFeatures.ExchangeImmutableIDs)),
|
|
}
|
|
)
|
|
|
|
mail, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
Messages().
|
|
ByMessageId(mailID).
|
|
Get(ctx, config)
|
|
if err != nil {
|
|
return nil, nil, clues.Stack(err)
|
|
}
|
|
|
|
mailBody = mail.GetBody()
|
|
if mailBody != nil {
|
|
content := ptr.Val(mailBody.GetContent())
|
|
if len(content) > 0 {
|
|
size = int64(len(content))
|
|
}
|
|
}
|
|
|
|
if !ptr.Val(mail.GetHasAttachments()) && !HasAttachments(mailBody) {
|
|
return mail, MailInfo(mail, size), nil
|
|
}
|
|
|
|
attachments, totalSize, err := c.getAttachments(ctx, userID, mailID)
|
|
if err != nil {
|
|
// A failure can be caused by having a lot of attachments.
|
|
// If that happens, we can progres with a two-step approach of:
|
|
// 1. getting all attachment IDs.
|
|
// 2. fetching each attachment individually.
|
|
logger.CtxErr(ctx, err).Info("falling back to fetching attachments by id")
|
|
|
|
attachments, totalSize, err = c.getAttachmentsIterated(
|
|
ctx,
|
|
userID,
|
|
mailID,
|
|
errs)
|
|
if err != nil {
|
|
return nil, nil, clues.Stack(err)
|
|
}
|
|
}
|
|
|
|
size += totalSize
|
|
|
|
mail.SetAttachments(attachments)
|
|
|
|
return mail, MailInfo(mail, size), nil
|
|
}
|
|
|
|
// getAttachments attempts to get all attachments, including their content, in a singe query.
|
|
func (c Mail) getAttachments(
|
|
ctx context.Context,
|
|
userID, mailID string,
|
|
) ([]models.Attachmentable, int64, error) {
|
|
var (
|
|
result = []models.Attachmentable{}
|
|
totalSize int64
|
|
cfg = &users.ItemMessagesItemAttachmentsRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMessagesItemAttachmentsRequestBuilderGetQueryParameters{
|
|
Expand: []string{"microsoft.graph.itemattachment/item"},
|
|
},
|
|
Headers: newPreferHeaders(
|
|
preferPageSize(maxNonDeltaPageSize),
|
|
preferImmutableIDs(c.options.ToggleFeatures.ExchangeImmutableIDs)),
|
|
}
|
|
)
|
|
|
|
attachments, err := c.LargeItem.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
Messages().
|
|
ByMessageId(mailID).
|
|
Attachments().
|
|
Get(ctx, cfg)
|
|
if err != nil {
|
|
return nil, 0, clues.Stack(err)
|
|
}
|
|
|
|
for _, a := range attachments.GetValue() {
|
|
totalSize += int64(ptr.Val(a.GetSize()))
|
|
result = append(result, a)
|
|
}
|
|
|
|
return result, totalSize, nil
|
|
}
|
|
|
|
// getAttachmentsIterated runs a two step fetch: one bulk query to get all attachment IDs,
|
|
// and then another lookup to fetch the content of each attachment.
|
|
// TODO: Once MS Graph fixes pagination for this, we can swap to a pager.
|
|
// https://learn.microsoft.com/en-us/answers/questions/1227026/pagination-not-working-when-fetching-message-attac
|
|
func (c Mail) getAttachmentsIterated(
|
|
ctx context.Context,
|
|
userID, mailID string,
|
|
errs *fault.Bus,
|
|
) ([]models.Attachmentable, int64, error) {
|
|
var (
|
|
result = []models.Attachmentable{}
|
|
totalSize int64
|
|
cfg = &users.ItemMessagesItemAttachmentsRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMessagesItemAttachmentsRequestBuilderGetQueryParameters{
|
|
Select: idAnd(),
|
|
},
|
|
Headers: newPreferHeaders(
|
|
preferPageSize(maxNonDeltaPageSize),
|
|
preferImmutableIDs(c.options.ToggleFeatures.ExchangeImmutableIDs)),
|
|
}
|
|
)
|
|
|
|
attachments, err := c.LargeItem.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
Messages().
|
|
ByMessageId(mailID).
|
|
Attachments().
|
|
Get(ctx, cfg)
|
|
if err != nil {
|
|
return nil, 0, clues.Wrap(err, "getting mail attachment ids")
|
|
}
|
|
|
|
for _, a := range attachments.GetValue() {
|
|
var (
|
|
aID = ptr.Val(a.GetId())
|
|
aODataType = ptr.Val(a.GetOdataType())
|
|
isItemAttachment = aODataType == "#microsoft.graph.itemAttachment"
|
|
)
|
|
|
|
ictx := clues.Add(
|
|
ctx,
|
|
"attachment_id", aID,
|
|
"attachment_odatatype", aODataType)
|
|
|
|
attachment, err := c.getAttachmentByID(
|
|
ictx,
|
|
userID,
|
|
mailID,
|
|
aID,
|
|
isItemAttachment,
|
|
errs)
|
|
if err != nil {
|
|
return nil, 0, clues.Stack(err)
|
|
}
|
|
|
|
if attachment != nil {
|
|
result = append(result, attachment)
|
|
totalSize += int64(ptr.Val(attachment.GetSize()))
|
|
}
|
|
}
|
|
|
|
return result, totalSize, nil
|
|
}
|
|
|
|
func (c Mail) getAttachmentByID(
|
|
ctx context.Context,
|
|
userID, mailID, attachmentID string,
|
|
isItemAttachment bool,
|
|
errs *fault.Bus,
|
|
) (models.Attachmentable, error) {
|
|
cfg := &users.ItemMessagesItemAttachmentsAttachmentItemRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMessagesItemAttachmentsAttachmentItemRequestBuilderGetQueryParameters{
|
|
Expand: []string{"microsoft.graph.itemattachment/item"},
|
|
},
|
|
Headers: newPreferHeaders(preferImmutableIDs(c.options.ToggleFeatures.ExchangeImmutableIDs)),
|
|
}
|
|
|
|
attachment, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
Messages().
|
|
ByMessageId(mailID).
|
|
Attachments().
|
|
ByAttachmentId(attachmentID).
|
|
Get(ctx, cfg)
|
|
if err != nil {
|
|
// CannotOpenFileAttachment errors are not transient and
|
|
// happens possibly from the original item somehow getting
|
|
// deleted from M365 and so we can skip these
|
|
if graph.IsErrCannotOpenFileAttachment(err) {
|
|
logger.CtxErr(ctx, err).Info("attachment not found")
|
|
errs.AddAlert(ctx, fault.NewAlert(
|
|
"cannot open attached file",
|
|
"", // no namespace
|
|
mailID,
|
|
"mailAttachment",
|
|
map[string]any{
|
|
"attachment_id": attachmentID,
|
|
"user_id": userID,
|
|
"is_item_attachment": isItemAttachment,
|
|
}))
|
|
// TODO This should use a `AddSkip` once we have
|
|
// figured out the semantics for skipping
|
|
// subcomponents of an item
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, clues.Wrap(err, "getting mail attachment by id")
|
|
}
|
|
|
|
return attachment, nil
|
|
}
|
|
|
|
func (c Mail) PostItem(
|
|
ctx context.Context,
|
|
userID, containerID string,
|
|
body models.Messageable,
|
|
) (models.Messageable, error) {
|
|
itm, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Messages().
|
|
Post(ctx, body, nil)
|
|
if err != nil {
|
|
return nil, clues.Wrap(err, "creating mail message")
|
|
}
|
|
|
|
if itm == nil {
|
|
return nil, clues.NewWC(ctx, "nil response mail message creation")
|
|
}
|
|
|
|
return itm, nil
|
|
}
|
|
|
|
func (c Mail) MoveItem(
|
|
ctx context.Context,
|
|
userID, oldContainerID, newContainerID, itemID string,
|
|
) (string, error) {
|
|
body := users.NewItemMailFoldersItemMessagesItemMovePostRequestBody()
|
|
body.SetDestinationId(ptr.To(newContainerID))
|
|
|
|
resp, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(oldContainerID).
|
|
Messages().
|
|
ByMessageId(itemID).
|
|
Move().
|
|
Post(ctx, body, nil)
|
|
if err != nil {
|
|
return "", clues.Wrap(err, "moving message")
|
|
}
|
|
|
|
return ptr.Val(resp.GetId()), nil
|
|
}
|
|
|
|
func (c Mail) DeleteItem(
|
|
ctx context.Context,
|
|
userID, itemID string,
|
|
) error {
|
|
// deletes require unique http clients
|
|
// https://github.com/alcionai/corso/issues/2707
|
|
srv, err := NewService(c.Credentials, c.counter)
|
|
if err != nil {
|
|
return clues.StackWC(ctx, err)
|
|
}
|
|
|
|
err = srv.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
Messages().
|
|
ByMessageId(itemID).
|
|
Delete(ctx, nil)
|
|
|
|
return clues.Wrap(err, "deleting mail message").OrNil()
|
|
}
|
|
|
|
func (c Mail) PostSmallAttachment(
|
|
ctx context.Context,
|
|
userID, containerID, parentItemID string,
|
|
body models.Attachmentable,
|
|
) error {
|
|
_, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Messages().
|
|
ByMessageId(parentItemID).
|
|
Attachments().
|
|
Post(ctx, body, nil)
|
|
|
|
return clues.Wrap(err, "uploading small mail attachment").OrNil()
|
|
}
|
|
|
|
func (c Mail) PostLargeAttachment(
|
|
ctx context.Context,
|
|
userID, containerID, parentItemID, itemName string,
|
|
content []byte,
|
|
) (string, error) {
|
|
size := int64(len(content))
|
|
session := users.NewItemMailFoldersItemMessagesItemAttachmentsCreateUploadSessionPostRequestBody()
|
|
session.SetAttachmentItem(makeSessionAttachment(itemName, size))
|
|
|
|
us, err := c.LargeItem.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(containerID).
|
|
Messages().
|
|
ByMessageId(parentItemID).
|
|
Attachments().
|
|
CreateUploadSession().
|
|
Post(ctx, session, nil)
|
|
if err != nil {
|
|
return "", clues.Wrap(err, "uploading large mail attachment")
|
|
}
|
|
|
|
url := ptr.Val(us.GetUploadUrl())
|
|
w := graph.NewLargeItemWriter(parentItemID, url, size, c.counter)
|
|
copyBuffer := make([]byte, graph.AttachmentChunkSize)
|
|
|
|
_, err = io.CopyBuffer(w, bytes.NewReader(content), copyBuffer)
|
|
if err != nil {
|
|
return "", clues.WrapWC(ctx, err, "buffering large attachment content")
|
|
}
|
|
|
|
return w.ID, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Serialization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func bytesToMessageable(body []byte) (serialization.Parsable, error) {
|
|
v, err := CreateFromBytes(body, models.CreateMessageFromDiscriminatorValue)
|
|
if err != nil {
|
|
if !strings.Contains(err.Error(), invalidJSON) {
|
|
return nil, clues.Wrap(err, "deserializing bytes to message")
|
|
}
|
|
|
|
// If the JSON was invalid try sanitizing and deserializing again.
|
|
// Sanitizing should transform characters < 0x20 according to the spec where
|
|
// possible. The resulting JSON may still be invalid though.
|
|
body = sanitize.JSONBytes(body)
|
|
v, err = CreateFromBytes(body, models.CreateMessageFromDiscriminatorValue)
|
|
}
|
|
|
|
return v, clues.Stack(err).OrNil()
|
|
}
|
|
|
|
func BytesToMessageable(body []byte) (models.Messageable, error) {
|
|
v, err := bytesToMessageable(body)
|
|
if err != nil {
|
|
return nil, clues.Stack(err)
|
|
}
|
|
|
|
return v.(models.Messageable), nil
|
|
}
|
|
|
|
func (c Mail) Serialize(
|
|
ctx context.Context,
|
|
item serialization.Parsable,
|
|
user, itemID string,
|
|
) ([]byte, error) {
|
|
msg, ok := item.(models.Messageable)
|
|
if !ok {
|
|
return nil, clues.New(fmt.Sprintf("item is not a Messageable: %T", item))
|
|
}
|
|
|
|
ctx = clues.Add(ctx, "item_id", ptr.Val(msg.GetId()))
|
|
writer := kjson.NewJsonSerializationWriter()
|
|
|
|
defer writer.Close()
|
|
|
|
if err := writer.WriteObjectValue("", msg); err != nil {
|
|
return nil, clues.StackWC(ctx, err)
|
|
}
|
|
|
|
bs, err := writer.GetSerializedContent()
|
|
|
|
return bs, clues.WrapWC(ctx, err, "serializing email").OrNil()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func MailInfo(msg models.Messageable, size int64) *details.ExchangeInfo {
|
|
var (
|
|
sender = unwrapEmailAddress(msg.GetSender())
|
|
subject = ptr.Val(msg.GetSubject())
|
|
received = ptr.Val(msg.GetReceivedDateTime())
|
|
created = ptr.Val(msg.GetCreatedDateTime())
|
|
recipients = make([]string, 0)
|
|
)
|
|
|
|
if msg.GetToRecipients() != nil {
|
|
ppl := msg.GetToRecipients()
|
|
for _, entry := range ppl {
|
|
temp := unwrapEmailAddress(entry)
|
|
if len(temp) > 0 {
|
|
recipients = append(recipients, temp)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &details.ExchangeInfo{
|
|
ItemType: details.ExchangeMail,
|
|
Sender: sender,
|
|
Recipient: recipients,
|
|
Subject: subject,
|
|
Received: received,
|
|
Size: size,
|
|
Created: created,
|
|
Modified: ptr.OrNow(msg.GetLastModifiedDateTime()),
|
|
}
|
|
}
|
|
|
|
func unwrapEmailAddress(contact models.Recipientable) string {
|
|
var empty string
|
|
if contact == nil || contact.GetEmailAddress() == nil {
|
|
return empty
|
|
}
|
|
|
|
return ptr.Val(contact.GetEmailAddress().GetAddress())
|
|
}
|
|
|
|
func mailCollisionKeyProps() []string {
|
|
return idAnd("subject", sentDateTime, receivedDateTime)
|
|
}
|
|
|
|
// MailCollisionKey constructs a key from the messageable's subject, sender, and recipients (to, cc, bcc).
|
|
// collision keys are used to identify duplicate item conflicts for handling advanced restoration config.
|
|
func MailCollisionKey(item models.Messageable) string {
|
|
if item == nil {
|
|
return ""
|
|
}
|
|
|
|
var (
|
|
subject = ptr.Val(item.GetSubject())
|
|
sent = ptr.Val(item.GetSentDateTime())
|
|
received = ptr.Val(item.GetReceivedDateTime())
|
|
)
|
|
|
|
return subject + dttm.Format(sent) + dttm.Format(received)
|
|
}
|