using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using ListmonkIntegration.Models; using TemplateInfo = ListmonkIntegration.Models.TemplateInfo; namespace ListmonkIntegration.Services { public class ListmonkService : IListmonkService { private readonly HttpClient _httpClient; private readonly ListmonkSettings _settings; private readonly ILogger _logger; public ListmonkService( HttpClient httpClient, ListmonkSettings settings, ILogger logger) { _httpClient = httpClient; _settings = settings; _logger = logger; // Настройка базового адреса _httpClient.BaseAddress = new Uri(_settings.BaseUrl); // Listmonk использует Basic Auth с API credentials // Username: логин пользователя (например, "admin") // Password: API токен (d1qPRY5jl1LsD1LskI4zWu4ukYWTyNLxjot05CjgRin4nQy3) var username = _settings.Username; var password = !string.IsNullOrEmpty(_settings.ApiToken) ? _settings.ApiToken : _settings.Password; var authToken = Convert.ToBase64String( Encoding.ASCII.GetBytes($"{username}:{password}")); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken); _logger.LogInformation($"Using Basic Auth with username: {username}"); } public async Task TestConnectionAsync() { try { _logger.LogInformation($"Testing connection to Listmonk..."); _logger.LogInformation($"Base URL: {_settings.BaseUrl}"); _logger.LogInformation($"Username: {_settings.Username}"); var response = await _httpClient.GetAsync("/api/config"); _logger.LogInformation($"Response status: {response.StatusCode}"); if (response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Success! Connected to Listmonk"); return true; } else { var errorContent = await response.Content.ReadAsStringAsync(); _logger.LogWarning($"Failed! Status: {response.StatusCode}, Content: {errorContent}"); return false; } } catch (Exception ex) { _logger.LogError(ex, $"Exception during connection test: {ex.Message}"); return false; } } public async Task CreateListAsync(string listName) { _logger.LogInformation($"Creating list: {listName}"); // Сначала проверим, нет ли уже списка с таким именем var existingListId = await FindListIdByNameAsync(listName); if (existingListId.HasValue) { _logger.LogInformation($"List '{listName}' already exists with ID {existingListId.Value}"); return existingListId.Value; } var content = new StringContent( JsonSerializer.Serialize(new { name = listName, type = "private", optin = "single" }), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync("/api/lists", content); var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Create list response: {responseBody}"); response.EnsureSuccessStatusCode(); var json = JsonDocument.Parse(responseBody); // Listmonk возвращает {"data": {"id": 1, ...}} var listId = json.RootElement .GetProperty("data") .GetProperty("id") .GetInt32(); _logger.LogInformation($"List created with ID: {listId}"); return listId; } private async Task FindListIdByNameAsync(string listName) { try { var response = await _httpClient.GetAsync("/api/lists"); if (response.IsSuccessStatusCode) { var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Lists response: {responseBody.Substring(0, Math.Min(200, responseBody.Length))}..."); var json = JsonDocument.Parse(responseBody); // Структура: {"data": {"results": [...]}} if (json.RootElement.TryGetProperty("data", out var data)) { JsonElement results; if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results)) { if (results.ValueKind == JsonValueKind.Array) { foreach (var list in results.EnumerateArray()) { var name = list.GetProperty("name").GetString(); if (name == listName) { var id = list.GetProperty("id").GetInt32(); _logger.LogInformation($"Found existing list '{listName}' with ID {id}"); return id; } } } } else if (data.ValueKind == JsonValueKind.Array) { foreach (var list in data.EnumerateArray()) { var name = list.GetProperty("name").GetString(); if (name == listName) { var id = list.GetProperty("id").GetInt32(); return id; } } } } } } catch (Exception ex) { _logger.LogWarning(ex, $"Error searching for list '{listName}'"); } return null; } public async Task ImportSubscribersAsync(List listIds, List subscribers) { _logger.LogInformation($"Importing {subscribers.Count} subscribers to lists: {string.Join(", ", listIds)}"); int successCount = 0; int failCount = 0; int updatedCount = 0; int createdCount = 0; foreach (var sub in subscribers) { try { // Проверяем, существует ли подписчик var existingSubscriber = await GetSubscriberByEmailAsync(sub.Email); if (existingSubscriber != null) { // Подписчик существует - обновляем его _logger.LogInformation($"Subscriber {sub.Email} exists, updating..."); // Получаем ID существующего подписчика var subscriberId = await GetSubscriberIdByEmailAsync(sub.Email); if (subscriberId.HasValue) { // Получаем текущие списки подписчика var currentListIds = await GetSubscriberListIdsAsync(subscriberId.Value); // Добавляем новые списки к существующим var allListIds = new List(currentListIds); foreach (var listId in listIds) { if (!allListIds.Contains(listId)) { allListIds.Add(listId); } } // Объединяем атрибуты var mergedAttributes = new Dictionary(existingSubscriber.Attributes); foreach (var attr in sub.Attributes) { mergedAttributes[attr.Key] = attr.Value; } // Обновляем подписчика var updated = await UpdateSubscriberAsync( subscriberId.Value, sub.Email, sub.FullName, mergedAttributes, allListIds ); if (updated) { updatedCount++; successCount++; } else { failCount++; } } else { failCount++; } } else { // Создаём нового подписчика await AddSubscriberToList(listIds, sub); createdCount++; successCount++; } } catch (Exception ex) { failCount++; _logger.LogWarning(ex, $"Failed to process subscriber {sub.Email}"); } } _logger.LogInformation($"Import completed: {successCount} success ({createdCount} created, {updatedCount} updated), {failCount} failed"); return true; } private async Task> GetSubscriberListIdsAsync(int subscriberId) { try { var response = await _httpClient.GetAsync($"/api/subscribers/{subscriberId}"); if (!response.IsSuccessStatusCode) return new List(); var responseBody = await response.Content.ReadAsStringAsync(); var json = JsonDocument.Parse(responseBody); var listIds = new List(); if (json.RootElement.TryGetProperty("data", out var data)) { if (data.TryGetProperty("lists", out var lists)) { foreach (var list in lists.EnumerateArray()) { if (list.TryGetProperty("id", out var id)) { listIds.Add(id.GetInt32()); } } } } return listIds; } catch (Exception ex) { _logger.LogError(ex, $"Error getting list IDs for subscriber {subscriberId}"); return new List(); } } private async Task AddSubscriberToList(List listIds, Subscriber subscriber) { var subscriberData = new { email = subscriber.Email, name = subscriber.FullName, lists = listIds, // Массив ID списков attribs = subscriber.Attributes, status = "enabled", mode = "subscribe" }; var jsonContent = JsonSerializer.Serialize(subscriberData); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync("/api/subscribers", content); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); _logger.LogWarning($"Failed to add subscriber {subscriber.Email}: {response.StatusCode} - {errorContent}"); throw new Exception($"Failed to add subscriber: {errorContent}"); } } public async Task CreateCampaignAsync(string name, int listId, int templateId) { var content = new StringContent( JsonSerializer.Serialize(new { name = name, subject = "Тема письма", lists = new[] { listId }, template_id = templateId, type = "regular", content_type = "html" }), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync("/api/campaigns", content); return response.IsSuccessStatusCode; } public async Task> GetListsAsync() { _logger.LogInformation("Fetching lists from Listmonk"); var response = await _httpClient.GetAsync("/api/lists"); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); _logger.LogError($"Failed to fetch lists: {response.StatusCode} - {errorContent}"); throw new Exception($"Failed to fetch lists: {response.StatusCode}"); } var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Raw response: {responseBody.Substring(0, Math.Min(500, responseBody.Length))}"); var json = JsonDocument.Parse(responseBody); var lists = new List(); // Структура: {"data": {"results": [...]}} if (json.RootElement.TryGetProperty("data", out var data)) { if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out var results)) { if (results.ValueKind == JsonValueKind.Array) { foreach (var list in results.EnumerateArray()) { var listInfo = new ListInfo { Id = list.GetProperty("id").GetInt32(), Name = list.GetProperty("name").GetString() ?? string.Empty, SubscriberCount = list.TryGetProperty("subscriber_count", out var countProp) ? countProp.GetInt32() : 0 }; lists.Add(listInfo); _logger.LogInformation($"Parsed list: ID={listInfo.Id}, Name={listInfo.Name}, Subscribers={listInfo.SubscriberCount}"); } } } else if (data.ValueKind == JsonValueKind.Array) { // На случай, если Listmonk вернёт массив напрямую foreach (var list in data.EnumerateArray()) { var listInfo = new ListInfo { Id = list.GetProperty("id").GetInt32(), Name = list.GetProperty("name").GetString() ?? string.Empty, SubscriberCount = list.TryGetProperty("subscriber_count", out var countProp) ? countProp.GetInt32() : 0 }; lists.Add(listInfo); } } } _logger.LogInformation($"Total lists found: {lists.Count}"); return lists; } public async Task> GetListAttributesAsync(int listId) { _logger.LogInformation($"Fetching attributes for list {listId}"); // Получаем ТОЛЬКО подписчиков из конкретного списка var response = await _httpClient.GetAsync($"/api/subscribers?list_id={listId}&per_page=1000"); if (!response.IsSuccessStatusCode) { _logger.LogWarning($"Failed to fetch subscribers for list {listId}: {response.StatusCode}"); return new List(); } var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Subscribers response for list {listId}: {responseBody.Substring(0, Math.Min(500, responseBody.Length))}"); var json = JsonDocument.Parse(responseBody); var attributes = new HashSet(); // Структура: {"data": {"results": [...]}} if (json.RootElement.TryGetProperty("data", out var data)) { JsonElement results; if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results)) { if (results.ValueKind == JsonValueKind.Array) { foreach (var subscriber in results.EnumerateArray()) { if (subscriber.TryGetProperty("attribs", out var attribs)) { foreach (var prop in attribs.EnumerateObject()) { attributes.Add(prop.Name); _logger.LogDebug($"Found attribute '{prop.Name}' for list {listId}"); } } } } } else if (data.ValueKind == JsonValueKind.Array) { foreach (var subscriber in data.EnumerateArray()) { if (subscriber.TryGetProperty("attribs", out var attribs)) { foreach (var prop in attribs.EnumerateObject()) { attributes.Add(prop.Name); } } } } } var result = attributes.ToList(); _logger.LogInformation($"Found {result.Count} unique attributes for list {listId}: {string.Join(", ", result)}"); return result; } public async Task CreateTemplateAsync(string name, string subject, string body) { _logger.LogInformation($"Creating template: {name}"); // Listmonk требует плейсхолдер {{ template "content" . }} в теле шаблона // Если его нет — добавляем автоматически if (!body.Contains("{{ template \"content\" . }}")) { body = body.TrimEnd() + "\n\n{{ template \"content\" . }}"; _logger.LogInformation("Added required placeholder to template body"); } var templateData = new { name = name, subject = subject, body = body, type = "campaign" }; var jsonContent = JsonSerializer.Serialize(templateData); _logger.LogInformation($"Request body: {jsonContent}"); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync("/api/templates", content); var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Response status: {response.StatusCode}"); _logger.LogInformation($"Response body: {responseBody}"); if (!response.IsSuccessStatusCode) { _logger.LogError($"Failed to create template: {response.StatusCode} - {responseBody}"); throw new Exception($"Failed to create template: {responseBody}"); } var json = JsonDocument.Parse(responseBody); int templateId; if (json.RootElement.TryGetProperty("data", out var data)) { templateId = data.GetProperty("id").GetInt32(); } else { templateId = json.RootElement.GetProperty("id").GetInt32(); } _logger.LogInformation($"Template created with ID: {templateId}"); return templateId; } public async Task> GetTemplatesAsync() { _logger.LogInformation("Fetching templates from Listmonk"); var response = await _httpClient.GetAsync("/api/templates"); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(); _logger.LogError($"Failed to fetch templates: {response.StatusCode} - {error}"); return new List(); } var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Templates response: {responseBody.Substring(0, Math.Min(500, responseBody.Length))}"); var json = JsonDocument.Parse(responseBody); var templates = new List(); if (json.RootElement.TryGetProperty("data", out var data)) { JsonElement results; if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results)) { if (results.ValueKind == JsonValueKind.Array) { foreach (var template in results.EnumerateArray()) { templates.Add(new TemplateInfo { Id = template.GetProperty("id").GetInt32(), Name = template.GetProperty("name").GetString() ?? string.Empty, Subject = template.TryGetProperty("subject", out var subj) ? subj.GetString() ?? string.Empty : string.Empty }); } } } else if (data.ValueKind == JsonValueKind.Array) { foreach (var template in data.EnumerateArray()) { templates.Add(new TemplateInfo { Id = template.GetProperty("id").GetInt32(), Name = template.GetProperty("name").GetString() ?? string.Empty, Subject = template.TryGetProperty("subject", out var subj) ? subj.GetString() ?? string.Empty : string.Empty }); } } } _logger.LogInformation($"Found {templates.Count} templates"); return templates; } public async Task GetTemplateBodyAsync(int templateId) { _logger.LogInformation($"Fetching template {templateId}"); var response = await _httpClient.GetAsync($"/api/templates/{templateId}"); response.EnsureSuccessStatusCode(); var responseBody = await response.Content.ReadAsStringAsync(); var json = JsonDocument.Parse(responseBody); if (json.RootElement.TryGetProperty("data", out var data)) { if (data.TryGetProperty("body", out var body)) { return body.GetString() ?? string.Empty; } } return string.Empty; } public async Task CreateCampaignAsync(string name, int listId, int templateId, string subject) { _logger.LogInformation($"Creating campaign: {name}"); _logger.LogInformation($"List ID: {listId}, Template ID: {templateId}, Subject: {subject}"); // Убираем send_at - кампания создастся как черновик, потом запустим отдельно var campaignData = new { name = name, subject = subject, lists = new[] { listId }, template_id = templateId, type = "regular", content_type = "html", messenger = "email" }; var jsonContent = JsonSerializer.Serialize(campaignData); _logger.LogInformation($"Request body: {jsonContent}"); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync("/api/campaigns", content); var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Response status: {response.StatusCode}"); _logger.LogInformation($"Response body: {responseBody}"); if (!response.IsSuccessStatusCode) { _logger.LogError($"Failed to create campaign: {response.StatusCode} - {responseBody}"); throw new Exception($"Failed to create campaign: {responseBody}"); } var json = JsonDocument.Parse(responseBody); int campaignId; if (json.RootElement.TryGetProperty("data", out var data)) { campaignId = data.GetProperty("id").GetInt32(); } else { campaignId = json.RootElement.GetProperty("id").GetInt32(); } _logger.LogInformation($"Campaign created with ID: {campaignId}"); return campaignId; } public async Task StartCampaignAsync(int campaignId) { _logger.LogInformation($"Starting campaign {campaignId}"); var statusData = new { status = "running" }; var jsonContent = JsonSerializer.Serialize(statusData); _logger.LogInformation($"Request body: {jsonContent}"); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); // ВАЖНО: PUT, не POST! var request = new HttpRequestMessage(HttpMethod.Put, $"/api/campaigns/{campaignId}/status") { Content = content }; var response = await _httpClient.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Response status: {response.StatusCode}"); _logger.LogInformation($"Response body: {responseBody}"); if (!response.IsSuccessStatusCode) { _logger.LogError($"Failed to start campaign: {response.StatusCode} - {responseBody}"); return false; } _logger.LogInformation($"Campaign {campaignId} started successfully"); return true; } public async Task GetSubscriberByEmailAsync(string email) { _logger.LogInformation($"Searching for subscriber by email: {email}"); try { // Ищем подписчика по email var response = await _httpClient.GetAsync($"/api/subscribers?query=LOWER(email)=LOWER('{email}')&per_page=1"); if (!response.IsSuccessStatusCode) { _logger.LogWarning($"Failed to search subscriber: {response.StatusCode}"); return null; } var responseBody = await response.Content.ReadAsStringAsync(); var json = JsonDocument.Parse(responseBody); if (json.RootElement.TryGetProperty("data", out var data)) { JsonElement results; if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results)) { if (results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0) { var subscriberData = results[0]; // Парсим атрибуты var attributes = new Dictionary(); if (subscriberData.TryGetProperty("attribs", out var attribs)) { foreach (var prop in attribs.EnumerateObject()) { attributes[prop.Name] = prop.Value.GetString() ?? string.Empty; } } // Получаем списки var listIds = new List(); if (subscriberData.TryGetProperty("lists", out var lists)) { foreach (var list in lists.EnumerateArray()) { if (list.TryGetProperty("id", out var id)) { listIds.Add(id.GetInt32()); } } } return new Subscriber { Email = subscriberData.GetProperty("email").GetString() ?? string.Empty, FullName = subscriberData.GetProperty("name").GetString() ?? string.Empty, Attributes = attributes }; } } } return null; } catch (Exception ex) { _logger.LogError(ex, $"Error searching subscriber {email}"); return null; } } public async Task UpdateSubscriberAsync(int subscriberId, string email, string name, Dictionary attributes, List listIds) { _logger.LogInformation($"Updating subscriber {subscriberId} ({email})"); var updateData = new { email = email, name = name, attribs = attributes, lists = listIds, status = "enabled" }; var jsonContent = JsonSerializer.Serialize(updateData); _logger.LogInformation($"Update data: {jsonContent}"); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); var response = await _httpClient.PutAsync($"/api/subscribers/{subscriberId}", content); var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation($"Update response: {response.StatusCode} - {responseBody}"); return response.IsSuccessStatusCode; } private async Task GetSubscriberIdByEmailAsync(string email) { try { var response = await _httpClient.GetAsync($"/api/subscribers?query=LOWER(email)=LOWER('{email}')&per_page=1"); if (!response.IsSuccessStatusCode) return null; var responseBody = await response.Content.ReadAsStringAsync(); var json = JsonDocument.Parse(responseBody); if (json.RootElement.TryGetProperty("data", out var data)) { JsonElement results; if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results)) { if (results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0) { return results[0].GetProperty("id").GetInt32(); } } } return null; } catch (Exception ex) { _logger.LogError(ex, $"Error getting subscriber ID for {email}"); return null; } } } }