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(); 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 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(message.HotelId, message.HotelId); var mailbox = await store.Get(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."); } } }