fix(dkv): correct invoice date extraction; add Exchange IsRead filter + mark-as-read
Date fix: previous regex matched payment-due date ("10 Tage nach Rechnungsdatum...
10.04.2026") instead of actual Rechnungsdatum. New approach anchors on the
invoice number line (DD/DDDDDDDDD/DDD) and takes the date on the next line,
which is always the actual Rechnungsdatum in DKV PDFs.
Exchange dedup: FindItem now filters IsRead=false (combined with sender filter
via <t:And>), so already-processed emails are skipped automatically.
After downloading attachments, UpdateItem marks the message as read
(using ItemId + ChangeKey from GetItem response), mirroring IMAP \Seen behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,17 +69,15 @@ export class DkvParserService {
|
|||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract invoice date ("DD.MM.YYYY") appearing near "Rechnungsdatum" in PDF text. */
|
/** Extract invoice date ("DD.MM.YYYY") from PDF text.
|
||||||
|
* DKV PDFs always print the date on the line immediately after the invoice
|
||||||
|
* number (DD/DDDDDDDDD/DDD), so we anchor on the number → date pairing.
|
||||||
|
* The old "Rechnungsdatum[:\s]*date" approach picked up the payment-due date
|
||||||
|
* ("10 Tage nach Rechnungsdatum. Die Abbuchung erfolgt am: 10.04.2026").
|
||||||
|
*/
|
||||||
private extractRechnungsdatum(text: string): string | null {
|
private extractRechnungsdatum(text: string): string | null {
|
||||||
// "Rechnungsdatum" is followed (within ~200 chars) by a date string
|
const m = text.match(/\d{2}\/\d{9}\/\d{3}\s+(\d{2}\.\d{2}\.\d{4})/);
|
||||||
const m = text.match(/Rechnungsdatum[:\s\t]*(\d{2}\.\d{2}\.\d{4})/);
|
return m ? m[1] : null;
|
||||||
if (m) return m[1];
|
|
||||||
// Fallback: second line after "Rechnungsdatum:" marker
|
|
||||||
const idx = text.indexOf('Rechnungsdatum');
|
|
||||||
if (idx === -1) return null;
|
|
||||||
const after = text.slice(idx, idx + 200);
|
|
||||||
const dm = after.match(/(\d{2}\.\d{2}\.\d{4})/);
|
|
||||||
return dm ? dm[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private: PDF text extraction ──────────────────────────────────────────
|
// ─── Private: PDF text extraction ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -24,14 +24,22 @@ function soapEnvelope(body: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findItemSoap(folderId: string, maxResults: number, senderFilter?: string): string {
|
function findItemSoap(folderId: string, maxResults: number, senderFilter?: string): string {
|
||||||
|
const isReadFilter = `<t:IsEqualTo>
|
||||||
|
<t:FieldURI FieldURI="message:IsRead"/>
|
||||||
|
<t:FieldURIOrConstant><t:Constant Value="false"/></t:FieldURIOrConstant>
|
||||||
|
</t:IsEqualTo>`;
|
||||||
|
|
||||||
const restriction = senderFilter
|
const restriction = senderFilter
|
||||||
? `<m:Restriction>
|
? `<m:Restriction>
|
||||||
<t:Contains ContainmentMode="Substring" ContainmentComparison="IgnoreCase">
|
<t:And>
|
||||||
<t:FieldURI FieldURI="message:From"/>
|
${isReadFilter}
|
||||||
<t:Constant Value="${escapeXml(senderFilter)}"/>
|
<t:Contains ContainmentMode="Substring" ContainmentComparison="IgnoreCase">
|
||||||
</t:Contains>
|
<t:FieldURI FieldURI="message:From"/>
|
||||||
|
<t:Constant Value="${escapeXml(senderFilter)}"/>
|
||||||
|
</t:Contains>
|
||||||
|
</t:And>
|
||||||
</m:Restriction>`
|
</m:Restriction>`
|
||||||
: '';
|
: `<m:Restriction>${isReadFilter}</m:Restriction>`;
|
||||||
|
|
||||||
return soapEnvelope(`
|
return soapEnvelope(`
|
||||||
<m:FindItem Traversal="Shallow">
|
<m:FindItem Traversal="Shallow">
|
||||||
@@ -71,6 +79,23 @@ function getItemSoap(itemIds: string[]): string {
|
|||||||
</m:GetItem>`);
|
</m:GetItem>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markReadSoap(itemId: string, changeKey: string): string {
|
||||||
|
return soapEnvelope(`
|
||||||
|
<m:UpdateItem MessageDisposition="SaveOnly" ConflictResolution="AlwaysOverwrite">
|
||||||
|
<m:ItemChanges>
|
||||||
|
<t:ItemChange>
|
||||||
|
<t:ItemId Id="${escapeXml(itemId)}" ChangeKey="${escapeXml(changeKey)}"/>
|
||||||
|
<t:Updates>
|
||||||
|
<t:SetItemField>
|
||||||
|
<t:FieldURI FieldURI="message:IsRead"/>
|
||||||
|
<t:Message><t:IsRead>true</t:IsRead></t:Message>
|
||||||
|
</t:SetItemField>
|
||||||
|
</t:Updates>
|
||||||
|
</t:ItemChange>
|
||||||
|
</m:ItemChanges>
|
||||||
|
</m:UpdateItem>`);
|
||||||
|
}
|
||||||
|
|
||||||
function getAttachmentSoap(attachmentId: string): string {
|
function getAttachmentSoap(attachmentId: string): string {
|
||||||
return soapEnvelope(`
|
return soapEnvelope(`
|
||||||
<m:GetAttachment>
|
<m:GetAttachment>
|
||||||
@@ -259,6 +284,7 @@ export class ExchangeInboxProvider implements InboxProvider {
|
|||||||
|
|
||||||
for (const block of messageBlocks) {
|
for (const block of messageBlocks) {
|
||||||
const uid = extractAttr(block, 't:ItemId', 'Id');
|
const uid = extractAttr(block, 't:ItemId', 'Id');
|
||||||
|
const changeKey = extractAttr(block, 't:ItemId', 'ChangeKey');
|
||||||
const subject = extractAll(block, 't:Subject')[0] ?? '';
|
const subject = extractAll(block, 't:Subject')[0] ?? '';
|
||||||
const messageId = extractAll(block, 't:InternetMessageId')[0] ?? '';
|
const messageId = extractAll(block, 't:InternetMessageId')[0] ?? '';
|
||||||
const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') ||
|
const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') ||
|
||||||
@@ -317,6 +343,15 @@ export class ExchangeInboxProvider implements InboxProvider {
|
|||||||
if (attachments.length === 0) continue;
|
if (attachments.length === 0) continue;
|
||||||
|
|
||||||
results.push({ uid, messageId, subject, from, date, attachments });
|
results.push({ uid, messageId, subject, from, date, attachments });
|
||||||
|
|
||||||
|
// Mark as read so subsequent polls skip this message (mirrors IMAP \Seen flag).
|
||||||
|
if (uid && changeKey) {
|
||||||
|
try {
|
||||||
|
await this.ewsPost(config, markReadSoap(uid, changeKey), 'UpdateItem');
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: message will reappear on next poll but IsRead filter will catch it
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user