/ firmware / src / services / email.cpp
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        + " &bull; 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