INSIGHT-MVP/packages/crm-service/src/graph/graph.service.ts
Thomas Reitz 6c51eb5e83 feat(crm): Kontakt-Detailseite – Breite, Outlook Daten, Outlook-Push
- max-width 960px auf Kontakt-Detailseite
- M365-Sektion umbenannt zu "Outlook Daten", default eingeklappt
- Aufgaben-Tab entfernt (nur noch E-Mails + Kalender)
- "In Outlook speichern"-Button: pusht/aktualisiert Kontakt in Outlook-Kontakte via MS Graph POST/PATCH /me/contacts
- Kontaktdaten: Typ, Status immer sichtbar, Bundesland (state) in Adresse
- Backend: GraphService exportiert, pushContactToOutlook-Methode, POST /crm/contacts/:id/push-to-outlook

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 19:57:07 +01:00

834 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Injectable,
Logger,
UnauthorizedException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from '../redis/redis.service';
export interface M365Email {
id: string;
subject: string;
bodyPreview: string;
receivedDateTime: string;
from: { emailAddress: { name: string; address: string } };
hasAttachments: boolean;
isRead: boolean;
webLink: string;
}
export interface M365CalendarEvent {
id: string;
subject: string;
start: { dateTime: string; timeZone: string };
end: { dateTime: string; timeZone: string };
location: { displayName: string };
organizer: { emailAddress: { name: string; address: string } };
attendees: Array<{ emailAddress: { name: string; address: string }; type: string }>;
isOnlineMeeting: boolean;
onlineMeetingUrl: string | null;
webLink: string;
}
export interface M365Task {
id: string;
title: string;
status: string;
importance: string;
dueDateTime: { dateTime: string; timeZone: string } | null;
completedDateTime: { dateTime: string; timeZone: string } | null;
createdDateTime: string;
}
export interface M365TaskList {
id: string;
displayName: string;
tasks: M365Task[];
}
/** Flache Aufgabe inkl. Body-Inhalt (für Dashboard Aufgaben-Tab) */
export interface M365TaskFlat {
id: string;
listId: string;
listName: string;
title: string;
status: string;
importance: string;
dueDateTime: { dateTime: string; timeZone: string } | null;
bodyContent: string | null; // Enthält "[INSIGHT_CRM:{activityId}]" wenn synchronisiert
createdDateTime: string;
}
export interface M365Contact {
id: string;
displayName: string;
emailAddresses: Array<{ name: string; address: string }>;
mobilePhone: string | null;
businessPhones: string[];
jobTitle: string | null;
companyName: string | null;
}
export interface M365UserProfile {
givenName: string | null;
surname: string | null;
displayName: string | null;
mobilePhone: string | null;
businessPhones: string[];
city: string | null;
streetAddress: string | null;
postalCode: string | null;
jobTitle: string | null;
department: string | null;
companyName: string | null;
officeLocation: string | null;
}
export interface M365MailFolder {
id: string;
displayName: string;
totalItemCount: number;
unreadItemCount: number;
childFolderCount: number;
wellKnownName: string | null;
}
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const CACHE_TTL = 300; // 5 Minuten
@Injectable()
export class GraphService {
private readonly logger = new Logger(GraphService.name);
private readonly coreServiceUrl: string;
constructor(
private readonly config: ConfigService,
private readonly redis: RedisService,
) {
this.coreServiceUrl =
this.config.get<string>('CORE_SERVICE_URL') ?? 'http://core:3000';
}
// ── Token vom Core-Service holen ──────────────────────────────────────
async getM365Token(userJwt: string): Promise<string> {
const url = `${this.coreServiceUrl}/api/v1/users/me/integrations/microsoft-365/token`;
let resp: Response;
try {
resp = await fetch(url, {
headers: { Authorization: `Bearer ${userJwt}` },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
throw new ServiceUnavailableException(
`Core-Service nicht erreichbar: ${(err as Error).message}`,
);
}
if (resp.status === 404) {
throw new UnauthorizedException(
'Keine Microsoft 365 Verbindung vorhanden — bitte zuerst verbinden',
);
}
if (!resp.ok) {
throw new ServiceUnavailableException(
`Core-Service Fehler: ${resp.status} ${resp.statusText}`,
);
}
const body = (await resp.json()) as {
success: boolean;
data: { accessToken: string };
};
if (!body.success || !body.data?.accessToken) {
throw new ServiceUnavailableException('Kein M365-Token erhalten');
}
return body.data.accessToken;
}
// ── Graph API Helpers ─────────────────────────────────────────────────
private async graphPost<T>(
accessToken: string,
path: string,
body: unknown,
): Promise<T> {
const resp = await fetch(`${GRAPH_BASE}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
const errBody = (await resp.json().catch(() => ({}))) as {
error?: { message?: string };
};
this.logger.error(
`Graph API POST Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`,
);
throw new ServiceUnavailableException(
`Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`,
);
}
return resp.json() as Promise<T>;
}
private async graphPatch<T>(
accessToken: string,
path: string,
body: unknown,
): Promise<T> {
const resp = await fetch(`${GRAPH_BASE}${path}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
const errBody = (await resp.json().catch(() => ({}))) as {
error?: { message?: string };
};
this.logger.error(
`Graph API PATCH Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`,
);
throw new ServiceUnavailableException(
`Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`,
);
}
// PATCH kann 204 No Content zurückgeben
if (resp.status === 204) return {} as T;
return resp.json() as Promise<T>;
}
private async graphGet<T>(
accessToken: string,
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(`${GRAPH_BASE}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
}
const resp = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
const errBody = (await resp.json().catch(() => ({}))) as {
error?: { message?: string };
};
this.logger.error(
`Graph API Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`,
);
throw new ServiceUnavailableException(
`Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`,
);
}
return resp.json() as Promise<T>;
}
// ── Kontakt-spezifische Abfragen ──────────────────────────────────────
async getContactEmails(
userJwt: string,
userId: string,
contactEmail: string,
): Promise<M365Email[]> {
const cacheKey = `graph:emails:${userId}:${contactEmail}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365Email[];
const accessToken = await this.getM365Token(userJwt);
// Hinweis: $search und $orderby können NICHT kombiniert werden (Graph API Limitation)
const data = await this.graphGet<{ value: M365Email[] }>(
accessToken,
'/me/messages',
{
$search: `"${contactEmail}"`,
$top: '25',
$select:
'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
},
);
const emails = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
this.logger.debug(`Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`);
return emails;
}
async getContactCalendar(
userJwt: string,
userId: string,
contactEmail: string,
): Promise<M365CalendarEvent[]> {
const cacheKey = `graph:calendar:${userId}:${contactEmail}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365CalendarEvent[];
const accessToken = await this.getM365Token(userJwt);
const now = new Date().toISOString();
const future = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
accessToken,
'/me/calendarView',
{
startDateTime: now,
endDateTime: future,
$top: '20',
$filter: `attendees/any(a:a/emailAddress/address eq '${contactEmail}')`,
$select:
'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink',
$orderby: 'start/dateTime asc',
},
);
const events = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL);
return events;
}
// ── Globale Office365-Übersicht ───────────────────────────────────────
/** Alle aktuellen E-Mails (Posteingang) */
async getAllEmails(userJwt: string, userId: string): Promise<M365Email[]> {
const cacheKey = `graph:all-emails:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365Email[];
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365Email[] }>(
accessToken,
'/me/messages',
{
$top: '50',
$orderby: 'receivedDateTime desc',
$select:
'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
},
);
const emails = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
this.logger.debug(`Graph: ${emails.length} E-Mails (global) geladen`);
return emails;
}
/** Alle Kalender-Ereignisse (nächste 30 Tage) */
async getAllCalendarEvents(
userJwt: string,
userId: string,
): Promise<M365CalendarEvent[]> {
const cacheKey = `graph:all-calendar:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365CalendarEvent[];
const accessToken = await this.getM365Token(userJwt);
const now = new Date().toISOString();
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
accessToken,
'/me/calendarView',
{
startDateTime: now,
endDateTime: future,
$top: '50',
$select:
'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink',
$orderby: 'start/dateTime asc',
},
);
const events = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL);
this.logger.debug(`Graph: ${events.length} Kalender-Ereignisse (global) geladen`);
return events;
}
/** Outlook-Kontakte des Benutzers */
async getAllOutlookContacts(
userJwt: string,
userId: string,
): Promise<M365Contact[]> {
const cacheKey = `graph:all-contacts:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365Contact[];
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365Contact[] }>(
accessToken,
'/me/contacts',
{
$top: '100',
$orderby: 'displayName asc',
$select:
'id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName',
},
);
const contacts = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(contacts), CACHE_TTL);
this.logger.debug(`Graph: ${contacts.length} Outlook-Kontakte geladen`);
return contacts;
}
/** Alle Task-Listen mit Aufgaben */
async getTasks(userJwt: string, userId: string): Promise<M365TaskList[]> {
const cacheKey = `graph:tasks:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365TaskList[];
const accessToken = await this.getM365Token(userJwt);
const listsData = await this.graphGet<{
value: Array<{ id: string; displayName: string }>;
}>(accessToken, '/me/todo/lists', { $top: '20' });
const lists: M365TaskList[] = [];
for (const list of listsData.value ?? []) {
const tasksData = await this.graphGet<{ value: M365Task[] }>(
accessToken,
`/me/todo/lists/${list.id}/tasks`,
{
$top: '50',
$filter: "status ne 'completed'",
$orderby: 'importance desc',
},
);
lists.push({
id: list.id,
displayName: list.displayName,
tasks: tasksData.value ?? [],
});
}
await this.redis.set(cacheKey, JSON.stringify(lists), CACHE_TTL);
return lists;
}
// ── Kalender: beliebiger Datumsbereich ───────────────────────────────
/** Kalender-Ereignisse für einen bestimmten Zeitraum (für Monats-/Wochenansicht) */
async getCalendarEventsForRange(
userJwt: string,
userId: string,
startDate: string, // YYYY-MM-DD (inklusiv)
endDate: string, // YYYY-MM-DD (exklusiv)
): Promise<M365CalendarEvent[]> {
const cacheKey = `graph:calendar-range:${userId}:${startDate}:${endDate}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365CalendarEvent[];
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
accessToken,
'/me/calendarView',
{
startDateTime: `${startDate}T00:00:00Z`,
endDateTime: `${endDate}T00:00:00Z`,
$top: '200',
$select: 'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink',
$orderby: 'start/dateTime asc',
},
);
const events = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL);
this.logger.debug(
`Graph: ${events.length} Kalender-Ereignisse (${startDate} ${endDate}) geladen`,
);
return events;
}
// ── Mail-Ordner ───────────────────────────────────────────────────────
/** Alle Mail-Ordner des Benutzers (Inbox, Gesendet, Entwürfe, …) */
async getMailFolders(userJwt: string, userId: string): Promise<M365MailFolder[]> {
const cacheKey = `graph:mail-folders:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365MailFolder[];
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365MailFolder[] }>(
accessToken,
'/me/mailFolders',
{
$top: '50',
// wellKnownName wird NICHT in $select aufgenommen — wird von vielen Exchange-Tenants
// nicht als selektierbares OData-Property unterstützt (400 Bad Request).
// Ordner-Identifikation erfolgt stattdessen über den displayName.
$select: 'id,displayName,totalItemCount,unreadItemCount,childFolderCount',
},
);
const folders = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(folders), CACHE_TTL);
this.logger.debug(`Graph: ${folders.length} Mail-Ordner geladen`);
return folders;
}
/** Alle offenen Aufgaben flach (inkl. Body für CRM-Sync-Erkennung) */
async getAllTasksFlat(userJwt: string, userId: string): Promise<M365TaskFlat[]> {
// Kein Cache — Aufgaben müssen immer aktuell sein
const accessToken = await this.getM365Token(userJwt);
const listsData = await this.graphGet<{
value: Array<{ id: string; displayName: string }>;
}>(accessToken, '/me/todo/lists', { $top: '20' });
const flatTasks: M365TaskFlat[] = [];
for (const list of listsData.value ?? []) {
const tasksData = await this.graphGet<{
value: Array<{
id: string;
title: string;
status: string;
importance: string;
dueDateTime: { dateTime: string; timeZone: string } | null;
body: { content: string; contentType: string } | null;
createdDateTime: string;
}>;
}>(accessToken, `/me/todo/lists/${list.id}/tasks`, {
$top: '100',
$filter: "status ne 'completed'",
$select: 'id,title,status,importance,dueDateTime,body,createdDateTime',
$orderby: 'importance desc',
});
for (const task of tasksData.value ?? []) {
flatTasks.push({
id: task.id,
listId: list.id,
listName: list.displayName,
title: task.title,
status: task.status,
importance: task.importance,
dueDateTime: task.dueDateTime ?? null,
bodyContent: task.body?.content ?? null,
createdDateTime: task.createdDateTime,
});
}
}
this.logger.debug(`Graph: ${flatTasks.length} Aufgaben (flat) fuer ${userId} geladen`);
return flatTasks;
}
/** Neue Aufgabe in Standard-Aufgabenliste erstellen */
async createM365Task(
userJwt: string,
title: string,
bodyContent?: string,
dueDateISO?: string,
): Promise<{ id: string; listId: string }> {
const accessToken = await this.getM365Token(userJwt);
// Standard-Liste finden ("Tasks" / "Aufgaben" / erste verfügbare Liste)
const listsData = await this.graphGet<{
value: Array<{ id: string; displayName: string; wellknownListName?: string }>;
}>(accessToken, '/me/todo/lists', { $top: '20' });
const lists = listsData.value ?? [];
const targetList =
lists.find(
(l) =>
l.displayName.toLowerCase() === 'tasks' ||
l.displayName.toLowerCase() === 'aufgaben' ||
(l as { wellknownListName?: string }).wellknownListName === 'defaultList',
) ?? lists[0];
if (!targetList) {
throw new ServiceUnavailableException('Keine M365-Aufgabenliste gefunden');
}
const taskPayload: Record<string, unknown> = { title };
if (bodyContent) {
taskPayload['body'] = { content: bodyContent, contentType: 'text' };
}
if (dueDateISO) {
taskPayload['dueDateTime'] = { dateTime: dueDateISO, timeZone: 'UTC' };
}
const created = await this.graphPost<{ id: string }>(
accessToken,
`/me/todo/lists/${targetList.id}/tasks`,
taskPayload,
);
this.logger.debug(`Graph: Aufgabe "${title}" in Liste "${targetList.displayName}" erstellt`);
return { id: created.id, listId: targetList.id };
}
/** Aufgabe als erledigt markieren */
async completeM365Task(
userJwt: string,
listId: string,
taskId: string,
): Promise<void> {
const accessToken = await this.getM365Token(userJwt);
await this.graphPatch<unknown>(
accessToken,
`/me/todo/lists/${listId}/tasks/${taskId}`,
{ status: 'completed' },
);
this.logger.debug(`Graph: Aufgabe ${taskId} als erledigt markiert`);
}
/** Microsoft-365-Benutzerprofil laden (für Profilanreicherung) */
async getM365Profile(userJwt: string): Promise<M365UserProfile> {
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{
givenName?: string | null;
surname?: string | null;
displayName?: string | null;
mobilePhone?: string | null;
businessPhones?: string[];
city?: string | null;
streetAddress?: string | null;
postalCode?: string | null;
jobTitle?: string | null;
department?: string | null;
companyName?: string | null;
officeLocation?: string | null;
}>(accessToken, '/me', {
$select:
'givenName,surname,displayName,mobilePhone,businessPhones,city,streetAddress,postalCode,jobTitle,department,companyName,officeLocation',
});
return {
givenName: data.givenName ?? null,
surname: data.surname ?? null,
displayName: data.displayName ?? null,
mobilePhone: data.mobilePhone ?? null,
businessPhones: data.businessPhones ?? [],
city: data.city ?? null,
streetAddress: data.streetAddress ?? null,
postalCode: data.postalCode ?? null,
jobTitle: data.jobTitle ?? null,
department: data.department ?? null,
companyName: data.companyName ?? null,
officeLocation: data.officeLocation ?? null,
};
}
/**
* CRM-Kontakt in Outlook-Kontakte pushen / synchronisieren.
* Sucht anhand der E-Mail-Adresse nach einem vorhandenen Outlook-Kontakt.
* Wenn vorhanden → PATCH (Update), sonst → POST (Neu anlegen).
*/
async pushContactToOutlook(
userJwt: string,
contact: {
firstName: string | null;
lastName: string | null;
companyName: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
position: string | null;
department: string | null;
street: string | null;
zip: string | null;
city: string | null;
state: string | null;
country: string | null;
website: string | null;
notes: string | null;
},
): Promise<{ created: boolean; outlookContactId: string }> {
const accessToken = await this.getM365Token(userJwt);
const displayName = [contact.firstName, contact.lastName]
.filter(Boolean)
.join(' ') || contact.companyName || '';
const outlookPayload: Record<string, unknown> = {
givenName: contact.firstName ?? '',
surname: contact.lastName ?? '',
jobTitle: contact.position ?? '',
department: contact.department ?? '',
companyName: contact.companyName ?? '',
businessHomePage: contact.website ?? '',
personalNotes: contact.notes ?? '',
};
if (contact.email) {
outlookPayload['emailAddresses'] = [
{ address: contact.email, name: displayName },
];
}
const businessPhones: string[] = [];
if (contact.phone) businessPhones.push(contact.phone);
if (businessPhones.length > 0) {
outlookPayload['businessPhones'] = businessPhones;
}
if (contact.mobile) {
outlookPayload['mobilePhone'] = contact.mobile;
}
if (contact.street || contact.zip || contact.city) {
outlookPayload['businessAddress'] = {
street: contact.street ?? '',
city: contact.city ?? '',
state: contact.state ?? '',
postalCode: contact.zip ?? '',
countryOrRegion: contact.country ?? '',
};
}
// Vorhandenen Outlook-Kontakt anhand der E-Mail suchen
let existingId: string | null = null;
if (contact.email) {
try {
const search = await this.graphGet<{ value: Array<{ id: string }> }>(
accessToken,
'/me/contacts',
{
$filter: `emailAddresses/any(a:a/address eq '${contact.email}')`,
$top: '1',
$select: 'id',
},
);
existingId = search.value?.[0]?.id ?? null;
} catch {
// Suche schlägt fehl → Neuanlage
}
}
if (existingId) {
await this.graphPatch<unknown>(
accessToken,
`/me/contacts/${existingId}`,
outlookPayload,
);
this.logger.debug(
`Graph: CRM-Kontakt in Outlook aktualisiert (${existingId})`,
);
return { created: false, outlookContactId: existingId };
}
const created = await this.graphPost<{ id: string }>(
accessToken,
'/me/contacts',
outlookPayload,
);
this.logger.debug(
`Graph: CRM-Kontakt in Outlook erstellt (${created.id})`,
);
return { created: true, outlookContactId: created.id };
}
/**
* Microsoft-365-Profilbild laden (96x96 JPEG).
* Gibt Base64 Data-URL zurück, oder null wenn kein Foto vorhanden (404).
*/
async getM365Photo(userJwt: string): Promise<string | null> {
const accessToken = await this.getM365Token(userJwt);
try {
const resp = await fetch(
`${GRAPH_BASE}/me/photos/96x96/$value`,
{
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(10000),
},
);
if (resp.status === 404 || resp.status === 400) {
this.logger.debug('Graph: Kein M365-Profilbild vorhanden (404/400)');
return null;
}
if (!resp.ok) {
this.logger.warn(`Graph: Profilbild-Fehler ${resp.status} — wird ignoriert`);
return null;
}
const arrayBuffer = await resp.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
return `data:image/jpeg;base64,${base64}`;
} catch (err) {
// Foto ist optional — Fehler niemals an den User propagieren
this.logger.warn(`Graph: getM365Photo Fehler: ${(err as Error).message}`);
return null;
}
}
/** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */
async getMailsByFolder(
userJwt: string,
userId: string,
folderId: string,
days: number,
): Promise<M365Email[]> {
const cacheKey = `graph:folder-mails:${userId}:${folderId}:${days}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365Email[];
const accessToken = await this.getM365Token(userJwt);
const params: Record<string, string> = {
$top: '50',
$orderby: 'receivedDateTime desc',
$select: 'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
};
if (days > 0) {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
params['$filter'] = `receivedDateTime ge ${since}`;
}
const data = await this.graphGet<{ value: M365Email[] }>(
accessToken,
`/me/mailFolders/${folderId}/messages`,
params,
);
const emails = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
this.logger.debug(
`Graph: ${emails.length} E-Mails in Ordner ${folderId} (days=${days}) geladen`,
);
return emails;
}
}