Skip to content

Commit cf0a8e1

Browse files
committed
fix(xpeng): IMAP-Poller Blocker-Fixes - robusteres Download + Retry-Logik
- Passwort-Regex: Doppelpunkt zwingend, "Code:" ergaenzt, kein False-Positive fuer "password will be sent" - Body-Fallback: nur kurze, leerraumfreie Strings (4-60 Zeichen) - HTML-Entities in Download-Links werden unescaped (& -> &) - downloadToTempFile: HTTP-Status wird geprueft (non-200 wirft sofort Exception) - downloadToTempFile: Groessenlimit wird streaming-inline erzwungen, kein post-hoc - processDownloadLinks: bei Fehler kein xpeng_received_mail-Record, Mail bleibt UNSEEN fuer Retry
1 parent dd07e93 commit cf0a8e1

2 files changed

Lines changed: 91 additions & 11 deletions

File tree

backend/src/main/java/com/evmonitor/application/imports/xpeng/XpengImapPoller.java

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public class XpengImapPoller {
4545
Pattern.CASE_INSENSITIVE);
4646

4747
private static final Pattern PASSWORD_PATTERN =
48-
Pattern.compile("(?:password|passwort|pw)[:\\s]+([\\S]{4,30})",
48+
Pattern.compile("(?:password|passwort|pw|code)\\s*:\\s*([\\S]{4,30})",
4949
Pattern.CASE_INSENSITIVE);
5050

5151
private static final Pattern DOWNLOAD_LINK_PATTERN =
@@ -220,6 +220,7 @@ private void processDownloadLinks(Message msg, XpengConnection conn, String mess
220220
log.info("XPeng IMAP: {} Download-Link(s) in Mail von '{}' gefunden", downloadLinks.size(), from);
221221

222222
String password = lookupStoredPassword(conn.getId());
223+
boolean allSucceeded = true;
223224

224225
for (String url : downloadLinks) {
225226
String filename = extractFilenameFromLinkContext(htmlBody, url);
@@ -238,16 +239,20 @@ private void processDownloadLinks(Message msg, XpengConnection conn, String mess
238239
job.getId(), conn.getId(), filename);
239240
}
240241
} catch (Exception e) {
241-
log.error("XPeng IMAP: Download-Fehler fuer URL '{}': {}", url, e.getMessage(), e);
242-
saveReceivedMailRecord(conn.getId(), messageId, filename, null, null);
242+
log.error("XPeng IMAP: Download-Fehler fuer URL '{}' - Mail bleibt ungelesen fuer Retry: {}", url, e.getMessage(), e);
243+
allSucceeded = false;
243244
} finally {
244245
if (tmp != null) {
245246
try { Files.deleteIfExists(tmp); } catch (Exception ignored) {}
246247
}
247248
}
248249
}
249250

250-
msg.setFlag(Flags.Flag.SEEN, true);
251+
if (allSucceeded) {
252+
msg.setFlag(Flags.Flag.SEEN, true);
253+
} else {
254+
log.warn("XPeng IMAP: Mindestens ein Download fehlgeschlagen - Mail bleibt UNSEEN, naechster Poll versucht es erneut");
255+
}
251256
}
252257

253258
private String lookupStoredPassword(UUID connectionId) {
@@ -265,15 +270,27 @@ private Path downloadToTempFile(String url) throws Exception {
265270
HttpRequest request = HttpRequest.newBuilder()
266271
.uri(URI.create(url))
267272
.timeout(Duration.ofSeconds(60))
273+
.header("User-Agent", "EV-Monitor-XPeng-Importer/1.0")
268274
.GET()
269275
.build();
270276
Path tmp = Files.createTempFile("xpeng-download-", ".xlsx");
271277
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
272-
try (InputStream body = response.body()) {
273-
long copied = Files.copy(body, tmp, StandardCopyOption.REPLACE_EXISTING);
274-
if (copied > MAX_DOWNLOAD_BYTES) {
275-
Files.deleteIfExists(tmp);
276-
throw new IllegalStateException("Download zu gross: " + copied + " Bytes (max " + MAX_DOWNLOAD_BYTES + ")");
278+
if (response.statusCode() != 200) {
279+
Files.deleteIfExists(tmp);
280+
throw new IllegalStateException("Download fehlgeschlagen: HTTP " + response.statusCode() + " fuer " + url);
281+
}
282+
try (InputStream body = response.body();
283+
var out = Files.newOutputStream(tmp)) {
284+
byte[] buf = new byte[65536];
285+
long total = 0;
286+
int n;
287+
while ((n = body.read(buf)) > 0) {
288+
total += n;
289+
if (total > MAX_DOWNLOAD_BYTES) {
290+
Files.deleteIfExists(tmp);
291+
throw new IllegalStateException("Download zu gross: >" + MAX_DOWNLOAD_BYTES + " Bytes");
292+
}
293+
out.write(buf, 0, n);
277294
}
278295
}
279296
return tmp;
@@ -295,7 +312,7 @@ static List<String> extractXpengDownloadLinks(String html) {
295312
List<String> links = new ArrayList<>();
296313
Matcher m = DOWNLOAD_LINK_PATTERN.matcher(html);
297314
while (m.find()) {
298-
links.add(m.group(1));
315+
links.add(m.group(1).replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">"));
299316
}
300317
return links;
301318
}
@@ -365,7 +382,8 @@ static String extractPassword(String body, String vinSuffix) {
365382
Matcher vm = vinPattern.matcher(trimmed);
366383
if (vm.find()) return vm.group(1);
367384
}
368-
return trimmed.length() < 200 && !trimmed.isEmpty() ? trimmed : null;
385+
// Fallback: kurzer Body ohne Leerzeichen = wahrscheinlich reines Passwort-Token
386+
return trimmed.length() >= 4 && trimmed.length() <= 60 && !trimmed.contains(" ") ? trimmed : null;
369387
}
370388

371389
private static String getPlainTextBody(Message msg) {

backend/src/test/java/com/evmonitor/application/imports/xpeng/XpengImapPollerTest.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,32 @@ void keywordMatchTakesPriorityOverVinSuffix() {
200200
"Password: MyPass - also 202512237070 here", "7070"));
201201
}
202202

203+
// Blocker 3a: "Code:" Keyword (echtes XPeng-Format)
204+
@Test
205+
void extractsCodeKeywordAsPassword() {
206+
assertEquals("202605287070", XpengImapPoller.extractPassword("Code: 202605287070", null));
207+
}
208+
209+
// Blocker 3b: "password will be sent" darf NICHT matchen
210+
@Test
211+
void doesNotExtractPasswordFromPasswordWillPhrase() {
212+
String body = "The password will be sent to you in a separate email.";
213+
assertNull(XpengImapPoller.extractPassword(body, null));
214+
}
215+
216+
// Blocker 3c: Body-Fallback darf nicht bei Sätzen (Leerzeichen) triggern
217+
@Test
218+
void bodyFallbackIgnoresTextWithSpaces() {
219+
String body = "Dear customer";
220+
assertNull(XpengImapPoller.extractPassword(body, null));
221+
}
222+
223+
// Blocker 3d: Body-Fallback greift bei tokenartigem Kurztext ohne Leerzeichen
224+
@Test
225+
void bodyFallbackWorksForTokenWithoutSpaces() {
226+
assertEquals("Abc12345", XpengImapPoller.extractPassword("Abc12345", null));
227+
}
228+
203229
// --- processMessage: password extracted from nested multipart ---
204230

205231
@Test
@@ -357,6 +383,19 @@ void returnsEmptyListForNullHtml() {
357383
assertTrue(links.isEmpty());
358384
}
359385

386+
// Blocker 5: &amp; in href-Attributen wird zu & unescaped
387+
@Test
388+
void unescapesHtmlEntitiesInDownloadLink() {
389+
String url = "https://mail.xiaopeng.com/alimail/openLinks/downloadMimeMetaDiskBigAttach?id=abc&amp;foo=bar";
390+
String html = "<a href=\"" + url + "\">下载</a>";
391+
392+
List<String> links = XpengImapPoller.extractXpengDownloadLinks(html);
393+
394+
assertEquals(1, links.size());
395+
assertEquals("https://mail.xiaopeng.com/alimail/openLinks/downloadMimeMetaDiskBigAttach?id=abc&foo=bar",
396+
links.get(0));
397+
}
398+
360399
@Test
361400
void extractsMultipleDownloadLinks() {
362401
String html = "<html><body>"
@@ -416,6 +455,29 @@ private List<String> getImapFolders() {
416455
return (List<String>) ReflectionTestUtils.getField(poller, "imapFolders");
417456
}
418457

458+
// --- Fix 4: Download-Fehler = kein Record + kein SEEN (Retry) ---
459+
460+
@Test
461+
void doesNotSaveRecordOrMarkSeenWhenDownloadFails() throws Exception {
462+
UUID token = UUID.randomUUID();
463+
String html = "<a href=\"https://mail.xiaopeng.com/alimail/openLinks/downloadMimeMetaDiskBigAttach?id=unreachable\">下载</a>";
464+
Message msg = mockMessage("<dl-fail@test.com>", "Re: XPeng [token:" + token + "]");
465+
// No XLSX attachment (isMimeType multipart/* false), HTML body with download link
466+
lenient().when(msg.isMimeType("multipart/*")).thenReturn(false);
467+
lenient().when(msg.isMimeType("text/html")).thenReturn(true);
468+
lenient().when(msg.getContent()).thenReturn(html);
469+
when(msg.getFrom()).thenReturn(new jakarta.mail.Address[]{});
470+
XpengConnection conn = buildConn(token, false);
471+
when(receivedMailRepo.existsByMessageId(any())).thenReturn(false);
472+
when(connectionRepo.findByRoutingToken(token)).thenReturn(Optional.of(conn));
473+
474+
// downloadToTempFile will throw (unreachable host) → allSucceeded = false
475+
invokeProcessMessage(msg);
476+
477+
verify(receivedMailRepo, never()).save(any());
478+
verify(msg, never()).setFlag(Flags.Flag.SEEN, true);
479+
}
480+
419481
// --- helpers ---
420482

421483
private static Message mockMessage(String messageId, String subject) throws Exception {

0 commit comments

Comments
 (0)