GuestOps/src/GuestOps.Api/ReplyDelivery.cs

69 lines
5.2 KiB
C#

using System.Net.Mail;
using System.Text;
using System.Text.RegularExpressions;
namespace GuestOps.Web;
public static class ReplyMime
{
public static string Address(string value) => value.IndexOfAny(['\r', '\n']) < 0 && MailAddress.TryCreate(value, out var address) && address.Address.All(c => c < 128) ? address.Address.ToLowerInvariant() : "";
public static string Build(Conversation message, Mailbox mailbox)
{
var d = message.Delivery ?? throw new InvalidOperationException("No approved delivery.");
if (Address(d.Recipient) != d.Recipient || d.Recipient.Length == 0 || Address(mailbox.Email) != mailbox.Email || string.IsNullOrWhiteSpace(d.Body) || d.Body.Length > 22000 || !Regex.IsMatch(message.RfcMessageId, @"^<[^<>\s]{1,900}>$") || !Regex.IsMatch(d.Id, "^[a-f0-9]{32}$") || !Regex.IsMatch(message.ProviderThreadId, "^[a-zA-Z0-9]+$")) throw new InvalidOperationException("Reply metadata is invalid. Re-import the message or reply in Gmail.");
var subject = message.Subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? message.Subject : "Re: " + message.Subject;
// Encode each short Unicode chunk separately to keep RFC 2047 header lines short.
var chunks = new List<string>(); var part = new StringBuilder();
foreach (var rune in subject.EnumerateRunes()) { part.Append(rune); if (Encoding.UTF8.GetByteCount(part.ToString()) >= 36) { chunks.Add(part.ToString()); part.Clear(); } }
if (part.Length > 0) chunks.Add(part.ToString());
var encodedSubject = string.Join("\r\n ", chunks.Select(x => "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes(x)) + "?="));
var body = Convert.ToBase64String(Encoding.UTF8.GetBytes(d.Body), Base64FormattingOptions.InsertLineBreaks);
var date = d.UpdatedAt.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss +0000", System.Globalization.CultureInfo.InvariantCulture);
var automaticHeaders=d.Automatic?"Auto-Submitted: auto-replied\r\nX-Auto-Response-Suppress: All\r\n":"";
var raw = $"{automaticHeaders}From: {mailbox.Email}\r\nTo: {d.Recipient}\r\nDate: {date}\r\nSubject: {encodedSubject}\r\nMessage-ID: {d.MessageId}\r\nIn-Reply-To: {message.RfcMessageId}\r\nReferences: {message.RfcMessageId}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{body}\r\n";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
public sealed class ReplyDelivery(IStore store, GoogleMailbox google, IConfiguration? config = null)
{
public async Task Process(Conversation message, CancellationToken ct)
{
if (message.Delivery == null) return;
async Task<bool> Save(string state, string detail)
{
var version = message.Version; message.Version++;
message.Delivery.State = state; message.Delivery.Detail = detail; message.Delivery.UpdatedAt = DateTime.UtcNow;
return await store.Replace(message.HotelId, message.Id, version, message);
}
if (message.Delivery.State == "Sending")
{
if (message.Delivery.UpdatedAt < DateTime.UtcNow.AddMinutes(-5)) await Save("NeedsReview", "Delivery was interrupted. Verify in Gmail before taking further action.");
return;
}
if (message.Delivery.State != "Pending") return;
// Compare-and-swap claims this immutable approval exactly once, across workers.
if (!await Save("Sending", "Submitting the approved reply")) return;
string raw, token;
try
{
var hotel = await store.Get<Hotel>(message.HotelId, message.HotelId);
var mailbox = await store.Get<Mailbox>(message.HotelId, message.MailboxId);
if (hotel?.StaffSendingEnabled != true || mailbox?.CanSend != true || mailbox.Status != "Connected" || mailbox.ConnectionEpoch!=message.Delivery.MailboxEpoch || !google.SendingConfigured) throw new InvalidOperationException("Sending disabled or mailbox connection changed.");
if(!await AutoReplyWork.CanDeliver(store,config,message))throw new InvalidOperationException("Automatic approval no longer valid.");
raw = ReplyMime.Build(message, mailbox);
token = await google.AccessToken(mailbox, ct);
if(message.Delivery.Automatic&&(!await google.AutoReplyThreadUnchanged(message,token,ct)||!await AutoReplyWork.CanDeliver(store,config,message)))throw new InvalidOperationException("Automatic reply no longer eligible.");
if(!await MailboxManagement.Current(store,mailbox))throw new MailboxConflict();
ct.ThrowIfCancellationRequested();
}
catch { await Save("Rejected", "Nothing was sent. Check reply metadata, hotel controls and Google connection, then retry the approved reply."); return; }
try
{
message.Delivery.ProviderId = await google.Send(message, token, raw, ct);
message.Status = "Completed";
await Save("Sent", "Gmail accepted the reply");
}
catch { await Save("NeedsReview", "Gmail's delivery result is uncertain. Verify delivery; this reply will not be automatically sent again."); }
}
}