Files
duplicati/proprietary/GoogleWorkspace/SourceItems/GroupConversationsSourceEntry.cs
T
Kenneth Skovhede 910c56b3c4 Reworked the use of scopes.
Instead of requesting all scopes up front, each sub-section of the backup/restore will request the permissions that are relevant for the operation.

The effect is that it is not required to grant all permissions to get a backup running. If permissions are failing, that part of the backup will generate warnings for items that are excluded.
2026-02-15 22:03:57 +01:00

217 lines
8.4 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
using Google.Apis.Requests;
using System.Runtime.CompilerServices;
namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
/// <summary>
/// Represents a folder containing conversations (emails) from a Google Group.
/// Uses the Gmail API to search for emails sent to the group address across all member mailboxes.
/// </summary>
internal class GroupConversationsSourceEntry(SourceProvider provider, string parentPath, string groupEmail)
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "conversations")), null, null)
{
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
{
// Get group members to find one mailbox to search
var memberEmails = await GetGroupMemberEmailsAsync(cancellationToken);
// Pick the first member, or fall back to the admin's mailbox
var userToSearch = memberEmails.Append("me").First();
await foreach (var entry in EnumerateMessagesForUser(userToSearch, cancellationToken))
yield return entry;
}
/// <summary>
/// Gets the email addresses of all group members.
/// </summary>
private async Task<List<string>> GetGroupMemberEmailsAsync(CancellationToken cancellationToken)
{
var memberEmails = new List<string>();
try
{
var service = provider.ApiHelper.GetDirectoryServiceForGroups();
var request = service.Members.List(groupEmail);
string? nextPageToken = null;
do
{
if (cancellationToken.IsCancellationRequested) break;
request.PageToken = nextPageToken;
var response = await request.ExecuteAsync(cancellationToken);
if (response.MembersValue != null)
{
foreach (var member in response.MembersValue)
{
// Only include USER type members (not groups or other entity types)
// and members with an email address
if (member.Type == "USER" && !string.IsNullOrEmpty(member.Email))
{
memberEmails.Add(member.Email);
}
}
}
nextPageToken = response.NextPageToken;
} while (!string.IsNullOrEmpty(nextPageToken));
}
catch (Exception)
{
// If we can't get members, we'll fall back to searching the admin's mailbox
}
return memberEmails;
}
/// <summary>
/// Enumerates messages for a specific user that were sent to the group.
/// </summary>
private async IAsyncEnumerable<ISourceProviderEntry> EnumerateMessagesForUser(
string userId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
GmailService service;
try
{
service = provider.ApiHelper.GetGmailService(userId);
}
catch
{
// If we can't access this user's mailbox, skip them
yield break;
}
// Search for emails sent to the group address
var request = service.Users.Messages.List(userId);
request.Q = $"to:{groupEmail} OR cc:{groupEmail}";
do
{
if (cancellationToken.IsCancellationRequested)
yield break;
ListMessagesResponse response;
try
{
response = await request.ExecuteAsync(cancellationToken);
}
catch
{
// If we can't search this user's mailbox, skip them
yield break;
}
if (response.Messages != null)
{
var batch = new BatchRequest(service);
var messages = new List<Message>();
foreach (var msg in response.Messages)
{
var msgRequest = service.Users.Messages.Get(userId, msg.Id);
msgRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Metadata;
batch.Queue<Message>(msgRequest, (message, error, index, httpResponse) =>
{
if (error == null)
{
lock (messages)
{
messages.Add(message);
}
}
});
}
if (batch.Count > 0)
{
await batch.ExecuteAsync(cancellationToken);
}
foreach (var message in messages)
{
if (cancellationToken.IsCancellationRequested)
yield break;
yield return new GroupConversationMessageSourceEntry(provider, userId, this.Path, message, groupEmail);
}
}
request.PageToken = response.NextPageToken;
} while (!string.IsNullOrEmpty(request.PageToken));
}
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
{
return Task.FromResult(new Dictionary<string, string?>
{
{ "gsuite:v", "1" },
{ "gsuite:Type", SourceItemType.GroupConversations.ToString() },
{ "gsuite:Name", "conversations" },
{ "gsuite:Id", groupEmail }
}
.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value));
}
}
/// <summary>
/// Represents a single email message from a Google Group conversation.
/// </summary>
internal class GroupConversationMessageSourceEntry(SourceProvider provider, string userId, string parentPath, Message message, string groupEmail)
: StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, message.Id + ".eml"),
message.InternalDate.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(message.InternalDate.Value).UtcDateTime : DateTime.UnixEpoch,
message.InternalDate.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(message.InternalDate.Value).UtcDateTime : DateTime.UnixEpoch)
{
public override long Size => message.SizeEstimate ?? 0;
public override async Task<Stream> OpenRead(CancellationToken cancellationToken)
{
var service = provider.ApiHelper.GetGmailService(userId);
var request = service.Users.Messages.Get(userId, message.Id);
request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
var msg = await request.ExecuteAsync(cancellationToken);
// Raw is base64url encoded string
var bytes = FromBase64Url(msg.Raw);
return new MemoryStream(bytes);
}
private static byte[] FromBase64Url(string base64Url)
{
string padded = base64Url.Length % 4 == 0
? base64Url
: base64Url + new string('=', 4 - base64Url.Length % 4);
string base64 = padded.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(base64);
}
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
{
var subject = message.Payload?.Headers?.FirstOrDefault(h => h.Name?.Equals("Subject", StringComparison.OrdinalIgnoreCase) == true)?.Value;
var from = message.Payload?.Headers?.FirstOrDefault(h => h.Name?.Equals("From", StringComparison.OrdinalIgnoreCase) == true)?.Value;
var date = message.Payload?.Headers?.FirstOrDefault(h => h.Name?.Equals("Date", StringComparison.OrdinalIgnoreCase) == true)?.Value;
return Task.FromResult(new Dictionary<string, string?>
{
{ "gsuite:v", "1" },
{ "gsuite:Type", SourceItemType.GroupConversations.ToString() },
{ "gsuite:Name", string.IsNullOrWhiteSpace(subject) ? message.Id : subject },
{ "gsuite:Id", message.Id },
{ "gsuite:ThreadId", message.ThreadId },
{ "gsuite:Snippet", message.Snippet },
{ "gsuite:GroupEmail", groupEmail },
{ "gsuite:From", from },
{ "gsuite:Date", date }
}
.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value));
}
}