email.cpp
1 #include "email.h" 2 3 #include <Arduino.h> 4 #include <Preferences.h> 5 #include <WiFi.h> 6 #include <WiFiClientSecure.h> 7 #include <time.h> 8 9 #define ENABLE_SMTP 10 #include <ReadyMail.h> 11 12 static WiFiClient smtp_plain_client; 13 static WiFiClientSecure smtp_secure_client; 14 static SMTPClient smtp_plain_session; 15 static SMTPClient smtp_secure_session; 16 static SMTPClient *active_session = nullptr; 17 static bool plain_initialized = false; 18 static bool secure_initialized = false; 19 static bool connected = false; 20 21 static bool load_password(String &out) { 22 Preferences prefs; 23 if (!prefs.begin("smtp", true)) return false; 24 out = prefs.isKey(config::smtp::NVS_KEY) 25 ? prefs.getString(config::smtp::NVS_KEY, "") 26 : ""; 27 prefs.end(); 28 return true; 29 } 30 31 static bool config_is_valid(const String &password) { 32 if (strlen(config::smtp::HOST) == 0 || strlen(config::smtp::DOMAIN) == 0) 33 return false; 34 if (config::smtp::AUTH_ENABLED == 1 35 && (strlen(config::smtp::LOGIN_EMAIL) == 0 || password.length() == 0)) 36 return false; 37 return true; 38 } 39 40 static void status_callback(SMTPStatus status) { 41 if (status.progress.available) { 42 Serial.printf("[email] uploading %s %u%%\n", 43 status.progress.filename.c_str(), 44 (unsigned)status.progress.value); 45 return; 46 } 47 if (status.text.length() > 0) 48 Serial.printf("[email] %s\n", status.text.c_str()); 49 } 50 51 static SMTPClient *select_session(void) { 52 if (config::smtp::SSL_ENABLED == 1 || config::smtp::STARTTLS_ENABLED == 1) { 53 smtp_secure_client.setInsecure(); 54 if (!secure_initialized) { 55 smtp_secure_session.begin(smtp_secure_client); 56 secure_initialized = true; 57 } 58 return &smtp_secure_session; 59 } 60 if (!plain_initialized) { 61 smtp_plain_session.begin(smtp_plain_client); 62 plain_initialized = true; 63 } 64 return &smtp_plain_session; 65 } 66 67 static String format_sender(void) { 68 if (strlen(config::smtp::FROM_NAME) == 0) 69 return String(config::smtp::FROM_EMAIL); 70 return String(config::smtp::FROM_NAME) + " <" + config::smtp::FROM_EMAIL + ">"; 71 } 72 73 static uint32_t message_timestamp(void) { 74 const time_t now = time(nullptr); 75 return now < 0 ? 0 : static_cast<uint32_t>(now); 76 } 77 78 static bool do_connect(const String &password) { 79 if (config::smtp::STARTTLS_ENABLED == 1) { 80 Serial.println("[email] STARTTLS configured but not supported in this build"); 81 return false; 82 } 83 84 active_session = select_session(); 85 if (!active_session) return false; 86 87 if (!active_session->connect(config::smtp::HOST, config::smtp::PORT, 88 config::smtp::DOMAIN, status_callback, 89 config::smtp::SSL_ENABLED == 1) 90 || !active_session->isConnected()) { 91 SMTPStatus s = active_session->status(); 92 Serial.printf("[email] connect failed: %d/%d %s\n", 93 s.statusCode, s.errorCode, s.text.c_str()); 94 return false; 95 } 96 97 if (config::smtp::AUTH_ENABLED == 1) { 98 if (!active_session->authenticate(config::smtp::LOGIN_EMAIL, password, 99 readymail_auth_password) 100 || !active_session->isAuthenticated()) { 101 SMTPStatus s = active_session->status(); 102 Serial.printf("[email] auth failed: %d/%d %s\n", 103 s.statusCode, s.errorCode, s.text.c_str()); 104 return false; 105 } 106 Serial.println("[email] connected and authenticated"); 107 } else { 108 Serial.println("[email] connected (no auth)"); 109 } 110 return true; 111 } 112 113 bool services::email::accessEndpoint(char *host, size_t host_len, uint16_t *port) { 114 if (!host || host_len == 0 || !port) return false; 115 116 String password; 117 load_password(password); 118 if (!config_is_valid(password)) return false; 119 120 strncpy(host, config::smtp::HOST, host_len - 1); 121 host[host_len - 1] = '\0'; 122 *port = config::smtp::PORT; 123 return host[0] != '\0'; 124 } 125 126 bool services::email::connect() { 127 String password; 128 load_password(password); 129 if (!config_is_valid(password)) { 130 Serial.println("[email] invalid config"); 131 return false; 132 } 133 134 if (connected && active_session && active_session->isConnected()) 135 return true; 136 137 if (!WiFi.isConnected()) { 138 Serial.println("[email] WiFi not connected"); 139 return false; 140 } 141 142 if (active_session && active_session->isConnected()) 143 active_session->stop(); 144 145 if (!do_connect(password)) 146 return false; 147 148 connected = true; 149 return true; 150 } 151 152 bool services::email::sendTest() { 153 if (strlen(config::smtp::HOST) == 0 || strlen(config::smtp::DOMAIN) == 0) { 154 Serial.println("[email] invalid config for test email"); 155 return false; 156 } 157 if (strlen(config::smtp::FROM_EMAIL) == 0 || strlen(config::smtp::TO_EMAIL) == 0) { 158 Serial.println("[email] missing from/to email"); 159 return false; 160 } 161 if (!services::email::connect()) return false; 162 163 SMTPMessage message; 164 message.headers.add(rfc822_from, format_sender()); 165 message.headers.add(rfc822_to, config::smtp::TO_EMAIL); 166 message.headers.add(rfc822_subject, 167 String(config::smtp::SUBJECT_PREFIX) + " SMTP test"); 168 message.headers.addCustom("Importance", "High"); 169 message.headers.addCustom("X-Priority", "1"); 170 171 message.text.body( 172 String("SMTP test from ceratina firmware.\r\n") 173 + "Host: " + config::smtp::HOST + ":" + String(config::smtp::PORT)); 174 175 message.html.body( 176 String("<html><body>") 177 + "<p>SMTP test from ceratina firmware.</p>" 178 + "<p>Host: " + config::smtp::HOST + ":" + String(config::smtp::PORT) + "</p>" 179 + "<p>Chip: " + ESP.getChipModel() + " rev" + String(ESP.getChipRevision()) 180 + " • Heap: " + String(ESP.getFreeHeap() / 1024) + " KB free</p>" 181 + "</body></html>"); 182 183 message.timestamp = message_timestamp(); 184 185 if (!active_session->send(message)) { 186 SMTPStatus s = active_session->status(); 187 Serial.printf("[email] send failed: %d/%d %s\n", 188 s.statusCode, s.errorCode, s.text.c_str()); 189 return false; 190 } 191 192 Serial.println("[email] test email sent"); 193 return true; 194 } 195 196 // ───────────────────────────────────────────────────────────────────────────── 197 // Tests 198 // ───────────────────────────────────────────────────────────────────────────── 199 #ifdef PIO_UNIT_TESTING 200 201 202 #include "email.h" 203 #include <testing/utils.h> 204 205 #include <Arduino.h> 206 #include <string.h> 207 208 static void test_email_endpoint_matches_flags(void) { 209 GIVEN("SMTP build flags"); 210 WHEN("the endpoint is queried"); 211 char host[128] = {0}; 212 uint16_t port = 0; 213 214 #if CERATINA_SMTP_ENABLED 215 bool ok = services::email::accessEndpoint(host, sizeof(host), &port); 216 TEST_ASSERT_TRUE_MESSAGE(ok, "get_endpoint returned false"); 217 TEST_ASSERT_EQUAL_STRING_MESSAGE(config::smtp::HOST, host, 218 "device: SMTP host mismatch"); 219 TEST_ASSERT_EQUAL_UINT16_MESSAGE(config::smtp::PORT, port, 220 "device: SMTP port mismatch"); 221 #else 222 bool ok = services::email::accessEndpoint(host, sizeof(host), &port); 223 TEST_ASSERT_FALSE_MESSAGE(ok, "should fail when SMTP not configured"); 224 225 #endif 226 } 227 228 static void test_email_flags_valid(void) { 229 GIVEN("SMTP is enabled"); 230 #if CERATINA_SMTP_ENABLED 231 TEST_ASSERT_NOT_EMPTY_MESSAGE(config::smtp::HOST, 232 "device: SMTP host must not be empty when enabled"); 233 TEST_ASSERT_NOT_EMPTY_MESSAGE(config::smtp::DOMAIN, 234 "device: SMTP domain must not be empty when enabled"); 235 TEST_ASSERT_GREATER_THAN_UINT16_MESSAGE(0, config::smtp::PORT, 236 "SMTP port must be > 0 when enabled"); 237 #else 238 TEST_IGNORE_MESSAGE("SMTP not enabled"); 239 #endif 240 } 241 242 static void test_email_connects(void) { 243 GIVEN("valid SMTP build flags"); 244 WHEN("a connection is attempted"); 245 #if CERATINA_SMTP_ENABLED 246 if (!WiFi.isConnected()) { 247 TEST_IGNORE_MESSAGE("WiFi not connected — skipping SMTP connect test"); 248 return; 249 } 250 251 bool ok = services::email::connect(); 252 TEST_ASSERT_TRUE_MESSAGE(ok, "SMTP connect should succeed with configured flags"); 253 #else 254 TEST_IGNORE_MESSAGE("SMTP not enabled"); 255 #endif 256 } 257 258 static void test_email_sends(void) { 259 GIVEN("an active SMTP connection"); 260 WHEN("a test email is sent"); 261 #if CERATINA_SMTP_ENABLED 262 if (!WiFi.isConnected()) { 263 TEST_IGNORE_MESSAGE("WiFi not connected — skipping SMTP send test"); 264 return; 265 } 266 267 bool ok = services::email::sendTest(); 268 TEST_ASSERT_TRUE_MESSAGE(ok, "SMTP test email should send successfully"); 269 #else 270 TEST_IGNORE_MESSAGE("SMTP not enabled"); 271 #endif 272 } 273 274 void services::email::test() { 275 MODULE("Email"); 276 RUN_TEST(test_email_endpoint_matches_flags); 277 RUN_TEST(test_email_flags_valid); 278 RUN_TEST(test_email_connects); 279 RUN_TEST(test_email_sends); 280 } 281 282 #endif