Files
duplicati/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Gmail.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

401 lines
17 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Text.Json;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Logging;
using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
using Google.Apis.Gmail.v1.Data;
namespace Duplicati.Proprietary.GoogleWorkspace;
partial class RestoreProvider
{
private GmailRestoreHelper? _gmailRestoreHelper = null;
internal GmailRestoreHelper GmailRestore => _gmailRestoreHelper ??= new GmailRestoreHelper(this);
internal class GmailRestoreHelper(RestoreProvider Provider)
{
private string? _targetUserId = null;
private string? _targetLabelId = null;
private bool _hasLoadedTargetInfo = false;
public async Task<(string? UserId, string? LabelId)> GetUserIdAndLabelTarget(CancellationToken cancel)
{
if (_hasLoadedTargetInfo)
{
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetLabelId))
return (null, null);
return (_targetUserId!, _targetLabelId!);
}
var target = Provider.RestoreTarget;
if (target == null)
throw new InvalidOperationException("Restore target is not set");
if (target.Type == SourceItemType.User)
{
_targetUserId = target.Metadata.GetValueOrDefault("gsuite:Id");
if (!string.IsNullOrWhiteSpace(_targetUserId))
_targetLabelId = await GetDefaultRestoreTargetLabel(_targetUserId, cancel);
}
else if (target.Type == SourceItemType.UserGmail)
{
_targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault();
if (string.IsNullOrWhiteSpace(_targetUserId))
throw new InvalidOperationException("User ID is not set");
_targetLabelId = await GetDefaultRestoreTargetLabel(_targetUserId, cancel);
}
else if (target.Type == SourceItemType.GmailLabel)
{
_targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault();
_targetLabelId = target.Metadata.GetValueOrDefault("gsuite:Id");
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreGmailInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring Gmail items.");
}
_hasLoadedTargetInfo = true;
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetLabelId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreGmailMissingIds", null, $"Missing target userId or labelId for restoring Gmail items.");
return (null, null);
}
return (_targetUserId, _targetLabelId);
}
private async Task<string> GetDefaultRestoreTargetLabel(string userId, CancellationToken cancel)
{
const string RESTORED_LABEL_NAME = "Restored";
var gmailService = Provider._apiHelper.GetGmailService(userId);
// Check if label already exists
var labelsResponse = await gmailService.Users.Labels.List(userId).ExecuteAsync(cancel);
var existingLabel = labelsResponse.Labels?.FirstOrDefault(l =>
l.Name?.Equals(RESTORED_LABEL_NAME, StringComparison.OrdinalIgnoreCase) == true);
if (existingLabel != null)
return existingLabel.Id;
// Create new label
var newLabel = new Label
{
Name = RESTORED_LABEL_NAME,
LabelListVisibility = "labelShow",
MessageListVisibility = "show"
};
var createdLabel = await gmailService.Users.Labels.Create(newLabel, userId).ExecuteAsync(cancel);
return createdLabel.Id;
}
public async Task RestoreLabel(string userId, Stream labelStream, CancellationToken cancel)
{
var label = await JsonSerializer.DeserializeAsync<Label>(labelStream, cancellationToken: cancel);
if (label == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
// Check if label already exists
var labelsResponse = await gmailService.Users.Labels.List(userId).ExecuteAsync(cancel);
var existingLabel = labelsResponse.Labels?.FirstOrDefault(l =>
l.Name?.Equals(label.Name, StringComparison.OrdinalIgnoreCase) == true);
if (existingLabel != null && Provider._ignoreExisting)
{
Log.WriteInformationMessage(LOGTAG, "RestoreLabelSkipExisting", $"Label {label.Name} already exists, skipping.");
return;
}
if (existingLabel != null)
{
// Update existing label
await gmailService.Users.Labels.Update(label, userId, existingLabel.Id).ExecuteAsync(cancel);
}
else
{
// Create new label
await gmailService.Users.Labels.Create(label, userId).ExecuteAsync(cancel);
}
}
public async Task RestoreMessage(string userId, string labelId, Stream messageStream, Stream? metadataStream, CancellationToken cancel)
{
var message = await JsonSerializer.DeserializeAsync<Message>(messageStream, cancellationToken: cancel);
if (message == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
// Check for duplicates by Message-Id if available
if (!Provider._ignoreExisting && metadataStream != null)
{
var metadata = await JsonSerializer.DeserializeAsync<Dictionary<string, string?>>(metadataStream, cancellationToken: cancel);
var messageId = metadata?.GetValueOrDefault("Message-Id");
if (!string.IsNullOrWhiteSpace(messageId))
{
// Search for existing message
var searchResponse = await gmailService.Users.Messages.List(userId).ExecuteAsync(cancel);
if (searchResponse.Messages?.Any() == true)
{
foreach (var existingMsg in searchResponse.Messages)
{
var fullMsg = await gmailService.Users.Messages.Get(userId, existingMsg.Id).ExecuteAsync(cancel);
var headers = fullMsg.Payload?.Headers;
var existingMessageId = headers?.FirstOrDefault(h => h.Name == "Message-Id")?.Value;
if (existingMessageId == messageId)
{
Log.WriteInformationMessage(LOGTAG, "RestoreMessageSkipDuplicate", $"Message with Message-Id {messageId} already exists, skipping.");
return;
}
}
}
}
}
// Import the message
var importRequest = new Message
{
Raw = message.Raw,
LabelIds = new List<string> { labelId }
};
await gmailService.Users.Messages.Import(importRequest, userId).ExecuteAsync(cancel);
}
public async Task RestoreSettings(string userId, Stream settingsStream, CancellationToken cancel)
{
var settings = await JsonSerializer.DeserializeAsync<ImapSettings>(settingsStream, cancellationToken: cancel);
if (settings == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
await gmailService.Users.Settings.UpdateImap(settings, userId).ExecuteAsync(cancel);
}
public async Task RestoreFilter(string userId, Stream filterStream, CancellationToken cancel)
{
var filter = await JsonSerializer.DeserializeAsync<Filter>(filterStream, cancellationToken: cancel);
if (filter == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
await gmailService.Users.Settings.Filters.Create(filter, userId).ExecuteAsync(cancel);
}
public async Task RestoreForwarding(string userId, Stream forwardingStream, CancellationToken cancel)
{
var forwarding = await JsonSerializer.DeserializeAsync<AutoForwarding>(forwardingStream, cancellationToken: cancel);
if (forwarding == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
await gmailService.Users.Settings.UpdateAutoForwarding(forwarding, userId).ExecuteAsync(cancel);
}
public async Task RestoreVacation(string userId, Stream vacationStream, CancellationToken cancel)
{
var vacation = await JsonSerializer.DeserializeAsync<VacationSettings>(vacationStream, cancellationToken: cancel);
if (vacation == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
await gmailService.Users.Settings.UpdateVacation(vacation, userId).ExecuteAsync(cancel);
}
public async Task RestoreSignature(string userId, string sendAsEmail, Stream signatureStream, CancellationToken cancel)
{
var sendAs = await JsonSerializer.DeserializeAsync<SendAs>(signatureStream, cancellationToken: cancel);
if (sendAs == null) return;
var gmailService = Provider._apiHelper.GetGmailService(userId);
await gmailService.Users.Settings.SendAs.Update(sendAs, userId, sendAsEmail).ExecuteAsync(cancel);
}
}
private async Task RestoreGmailLabels(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var labels = GetMetadataByType(SourceItemType.GmailLabel);
if (labels.Count == 0)
return;
(var userId, var defaultLabelId) = await GmailRestore.GetUserIdAndLabelTarget(cancel);
if (string.IsNullOrWhiteSpace(userId))
return;
foreach (var label in labels)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = label.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGmailLabelsMissingContent", null, $"Missing content for label {originalPath}, skipping.");
continue;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
await GmailRestore.RestoreLabel(userId, contentStream, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGmailLabelsFailed", ex, $"Failed to restore label {label.Key}");
}
}
}
private async Task RestoreGmailMessages(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var messages = GetMetadataByType(SourceItemType.GmailMessage);
if (messages.Count == 0)
return;
(var userId, var defaultLabelId) = await GmailRestore.GetUserIdAndLabelTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(defaultLabelId))
return;
foreach (var message in messages)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = message.Key;
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
var metadataPath = SystemIO.IO_OS.PathCombine(originalPath, "metadata.json");
var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath);
var metadataEntry = _temporaryFiles.GetValueOrDefault(metadataPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGmailMessagesMissingContent", null, $"Missing content for message {originalPath}, skipping.");
continue;
}
// Determine target label
var targetLabelId = defaultLabelId;
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
if (parentPath != null && _restoredLabelMap.TryGetValue(parentPath, out var mappedLabelId))
{
targetLabelId = mappedLabelId;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
Stream? metadataStream = null;
if (metadataEntry != null)
metadataStream = SystemIO.IO_OS.FileOpenRead(metadataEntry);
try
{
await GmailRestore.RestoreMessage(userId, targetLabelId, contentStream, metadataStream, cancel);
}
finally
{
metadataStream?.Dispose();
}
}
_metadata.TryRemove(originalPath, out _);
_metadata.TryRemove(contentPath, out _);
_metadata.TryRemove(metadataPath, out _);
_temporaryFiles.TryRemove(contentPath, out var cFile);
_temporaryFiles.TryRemove(metadataPath, out var mFile);
cFile?.Dispose();
mFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGmailMessagesFailed", ex, $"Failed to restore message {message.Key}");
}
}
}
private async Task RestoreGmailSettings(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var settings = GetMetadataByType(SourceItemType.GmailSettings);
if (settings.Count == 0)
return;
(var userId, _) = await GmailRestore.GetUserIdAndLabelTarget(cancel);
if (string.IsNullOrWhiteSpace(userId))
return;
foreach (var setting in settings)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = setting.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGmailSettingsMissingContent", null, $"Missing content for setting {originalPath}, skipping.");
continue;
}
var settingType = setting.Value.GetValueOrDefault("gsuite:SettingType");
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
switch (settingType)
{
case "ImapSettings":
await GmailRestore.RestoreSettings(userId, contentStream, cancel);
break;
case "Filter":
await GmailRestore.RestoreFilter(userId, contentStream, cancel);
break;
case "AutoForwarding":
await GmailRestore.RestoreForwarding(userId, contentStream, cancel);
break;
case "VacationSettings":
await GmailRestore.RestoreVacation(userId, contentStream, cancel);
break;
case "SendAs":
var sendAsEmail = setting.Value.GetValueOrDefault("gsuite:SendAsEmail") ?? userId;
await GmailRestore.RestoreSignature(userId, sendAsEmail, contentStream, cancel);
break;
default:
Log.WriteWarningMessage(LOGTAG, "RestoreGmailSettingsUnknownType", null, $"Unknown setting type {settingType} for {originalPath}, skipping.");
break;
}
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGmailSettingsFailed", ex, $"Failed to restore setting {setting.Key}");
}
}
}
}