using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using PowderCoating.Application.Interfaces; using Twilio; using Twilio.Rest.Api.V2010.Account; using Twilio.Types; namespace PowderCoating.Infrastructure.Services; public class SmsService : ISmsService { private readonly IConfiguration _configuration; private readonly IWebHostEnvironment _env; private readonly ILogger _logger; /// /// Initializes a new instance of . Twilio credentials are read /// per call (not cached here) because they may be rotated without restarting the application, /// and Twilio's client initialization (TwilioClient.Init) is idempotent and cheap. /// /// In non-production environments, all outbound messages are redirected to /// Twilio:DevRedirectPhone (if configured) so real customer numbers are never /// texted during development or staging. The original destination is prepended to the /// message body for traceability. /// /// public SmsService(IConfiguration configuration, IWebHostEnvironment env, ILogger logger) { _configuration = configuration; _env = env; _logger = logger; } /// /// Sends an SMS message via the Twilio REST API and returns a success/error tuple. /// The method short-circuits with a logged warning (not an exception) when Twilio is not /// configured, so callers in notification workflows are not broken in development /// environments where Twilio credentials are absent. Phone numbers are normalized via /// before sending because users enter phone numbers in many /// formats (with dashes, parentheses, spaces, or a leading "1") and Twilio requires strict /// E.164 format. The method returns the Twilio message SID on success so callers can log /// it for delivery tracking. Carrier regulations (TCPA in the US) require that the first /// SMS to a new number include opt-out instructions ("Reply STOP to unsubscribe") — this /// is enforced in the message body by the calling controller or notification service before /// this method is invoked; itself is transport-only and does not /// inspect or modify the message body. /// public async Task<(bool Success, string? ErrorMessage)> SendSmsAsync(string toPhone, string message) { var accountSid = _configuration["Twilio:AccountSid"]; var authToken = _configuration["Twilio:AuthToken"]; var fromNumber = _configuration["Twilio:FromNumber"]; if (string.IsNullOrWhiteSpace(accountSid) || accountSid.StartsWith("your-") || string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-") || string.IsNullOrWhiteSpace(fromNumber) || fromNumber.Contains("XXXXXXXXXX")) { _logger.LogWarning("Twilio is not configured. SMS to {ToPhone} skipped.", toPhone); return (false, "Twilio not configured"); } var normalizedPhone = NormalizePhone(toPhone); if (string.IsNullOrEmpty(normalizedPhone)) { _logger.LogWarning("Invalid phone number: {ToPhone}", toPhone); return (false, "Invalid phone number"); } // Non-production guard: redirect all messages to the dev phone so real customers // are never texted outside of production. Double-gated on environment name AND // the config value so a misconfigured prod deploy can't accidentally redirect. var devRedirect = _configuration["Twilio:DevRedirectPhone"]; if (!_env.IsProduction() && !string.IsNullOrWhiteSpace(devRedirect)) { var devPhone = NormalizePhone(devRedirect); if (!string.IsNullOrEmpty(devPhone)) { _logger.LogWarning("Non-production environment: redirecting SMS from {Original} to dev number {Dev}", normalizedPhone, devPhone); message = $"[DEV → {normalizedPhone}] {message}"; normalizedPhone = devPhone; } } try { TwilioClient.Init(accountSid, authToken); var smsMessage = await MessageResource.CreateAsync( to: new PhoneNumber(normalizedPhone), from: new PhoneNumber(fromNumber), body: message); _logger.LogInformation("SMS sent to {ToPhone}: SID={Sid}", normalizedPhone, smsMessage.Sid); return (true, null); } catch (Exception ex) { _logger.LogError(ex, "Exception sending SMS to {ToPhone}", normalizedPhone); return (false, ex.Message); } } /// /// Normalizes a phone number string to E.164 format (e.g., "+12025551234") required by /// Twilio. Strips all non-digit characters, then applies three rules in priority order: /// an 11-digit string starting with "1" is assumed to be a North American number with /// country code already included; a 10-digit string is assumed to be a US/Canada number /// and gets "+1" prepended; a string that already starts with "+" and has more than 10 /// digits is treated as an international number and reformatted with "+". Numbers that /// do not match any pattern return null, causing to abort with /// a warning rather than sending to a malformed number (which would generate a Twilio 400 /// error and count against the account's error rate). /// private static string? NormalizePhone(string phone) { if (string.IsNullOrWhiteSpace(phone)) return null; // Strip everything except digits var digits = new string(phone.Where(char.IsDigit).ToArray()); // Already E.164 with country code if (digits.Length == 11 && digits[0] == '1') return "+" + digits; // 10-digit US number — prepend +1 if (digits.Length == 10) return "+1" + digits; // Already formatted (e.g. +44...) if (phone.StartsWith("+") && digits.Length > 10) return "+" + digits; return null; } }