Private
Public Access
1
0

feat: integrate Microsoft Power Apps SDK and enhance email handling

- Added dependency for @microsoft/power-apps to package.json.
- Updated power.config.json to include appId, environmentId, and connection references.
- Implemented sanitisation function for HTML content in App.tsx to prevent XSS.
- Enhanced error handling in email loading and marking as read functionalities.
- Updated email display logic to handle HTML content and previews.
- Refactored OutlookService to use auto-generated service from @microsoft/power-apps.
- Added new methods for sending, marking as read, and deleting emails in OutlookService.
- Updated types for Email to include bodyPreview, isHtml, hasAttachments, and importance.
- Configured Vite to exclude @microsoft/power-apps/data from the build.
- Created .gitignore to exclude build artifacts and environment files.
- Added local development shim for @microsoft/power-apps/data to support local testing.
- Defined type stubs for @microsoft/power-apps/data to facilitate local development.
This commit is contained in:
Lago
2026-04-16 22:42:40 +02:00
parent 96f76e21a7
commit 0a1d96a40f
35 changed files with 2558 additions and 57689 deletions

View File

@@ -48,6 +48,14 @@ import { OutlookService } from './services/OutlookService';
const outlookService = new OutlookService();
/** Sanitise HTML content - strip script tags for basic XSS prevention */
function sanitiseHtml(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/on\w+="[^"]*"/gi, '')
.replace(/on\w+='[^']*'/gi, '');
}
/* ─── styles ─── */
const useStyles = makeStyles({
root: {
@@ -426,14 +434,20 @@ export default function App() {
const [composeSubject, setComposeSubject] = useState('');
const [composeBody, setComposeBody] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
/* data */
const loadEmails = useCallback(async () => {
setLoading(true);
setError(null);
try {
const msgs = await outlookService.getMessages(activeFolder);
setEmails(msgs);
setSelectedEmail(null);
} catch (err) {
console.error('Error loading emails:', err);
setError('No se pudieron cargar los correos. Comprueba la conexión.');
setEmails([]);
} finally {
setLoading(false);
}
@@ -446,8 +460,22 @@ export default function App() {
const selectEmail = async (email: Email) => {
setSelectedEmail(email);
if (!email.isRead) {
await outlookService.markAsRead(email.id);
setEmails(prev => prev.map(e => (e.id === email.id ? { ...e, isRead: true } : e)));
try {
await outlookService.markAsRead(email.id);
setEmails(prev => prev.map(e => (e.id === email.id ? { ...e, isRead: true } : e)));
} catch (err) {
console.error('Error marking as read:', err);
}
}
};
const handleDelete = async (email: Email) => {
try {
await outlookService.deleteEmail(email.id);
setEmails(prev => prev.filter(e => e.id !== email.id));
if (selectedEmail?.id === email.id) setSelectedEmail(null);
} catch (err) {
console.error('Error deleting email:', err);
}
};
@@ -524,12 +552,12 @@ export default function App() {
<aside className={s.folderPane}>
<div className={s.folderHeader}>
<Avatar
name="Lago"
initials="LA"
name="User"
initials="US"
size={28}
color="brand"
/>
<Text weight="semibold" size={300}>lago@powerplatform.top</Text>
<Text weight="semibold" size={300}>user@contoso.com</Text>
</div>
<Divider style={{ margin: '6px 0' }} />
@@ -601,6 +629,14 @@ export default function App() {
<div className={s.emptyState} style={{ padding: '40px 0' }}>
<Spinner size="small" label="Cargando…" />
</div>
) : error ? (
<div className={s.emptyState} style={{ padding: '60px 16px' }}>
<Mail24Regular fontSize={40} style={{ opacity: 0.3 }} />
<Text size={300} style={{ textAlign: 'center' }}>{error}</Text>
<Button appearance="subtle" size="small" icon={<ArrowSync24Regular />} onClick={loadEmails}>
Reintentar
</Button>
</div>
) : filtered.length === 0 ? (
<div className={s.emptyState} style={{ padding: '60px 16px' }}>
<Mail24Regular fontSize={40} style={{ opacity: 0.3 }} />
@@ -632,7 +668,7 @@ export default function App() {
<span className={s.messageTime}>{formatTime(email.receivedAt)}</span>
</div>
<div className={s.messageSubject}>{email.subject}</div>
<div className={s.messagePreview}>{email.body.substring(0, 80)}</div>
<div className={s.messagePreview}>{email.bodyPreview || email.body.substring(0, 80)}</div>
</div>
</div>
))
@@ -672,7 +708,14 @@ export default function App() {
<Divider style={{ margin: '0 28px' }} />
<div className={s.readingBody}>{selectedEmail.body}</div>
{selectedEmail.isHtml ? (
<div
className={s.readingBody}
dangerouslySetInnerHTML={{ __html: sanitiseHtml(selectedEmail.body) }}
/>
) : (
<div className={s.readingBody}>{selectedEmail.body}</div>
)}
<Divider style={{ margin: '0 28px' }} />
@@ -697,6 +740,7 @@ export default function App() {
appearance="subtle"
icon={<Delete24Regular />}
size="small"
onClick={() => handleDelete(selectedEmail)}
>
Eliminar
</Button>
@@ -806,11 +850,9 @@ function ComposeDialog({
function ConnectionsPanel() {
const s = useStyles();
// Connections from power.config.json — replace with your own
const connections = [
{ id: '1', name: 'shared_outlook', displayName: 'Outlook', connector: 'Microsoft Outlook', status: 'connected' as const, color: '#0078D4' },
{ id: '2', name: 'shared_office365', displayName: 'Office 365 Users', connector: 'Office 365 Users', status: 'connected' as const, color: '#D83B01' },
{ id: '3', name: 'shared_dataverse', displayName: 'Dataverse', connector: 'Microsoft Dataverse', status: 'connected' as const, color: '#7719BA' },
{ id: '4', name: 'shared_azuresql', displayName: 'Azure SQL', connector: 'Azure SQL Database', status: 'disconnected' as const, color: '#0078D4' },
{ id: 'your-connection-ref-id', name: 'shared_outlook', displayName: 'Outlook.com', connector: 'Microsoft Outlook', status: 'connected' as const, color: '#0078D4' },
];
const archBoxes = [
@@ -818,7 +860,7 @@ function ConnectionsPanel() {
{ label: 'PAC Client', bg: tokens.colorNeutralBackground4, fg: tokens.colorNeutralForeground1 },
{ label: 'Connection Ref', bg: tokens.colorNeutralBackground4, fg: tokens.colorNeutralForeground1 },
{ label: 'Connection', bg: tokens.colorNeutralBackground4, fg: tokens.colorNeutralForeground1 },
{ label: 'Outlook / Dataverse', bg: '#7719BA', fg: '#fff' },
{ label: 'Outlook', bg: '#0078D4', fg: '#fff' },
];
return (
@@ -887,7 +929,7 @@ function ConnectionsPanel() {
<br />
<div style={{ color: tokens.colorNeutralForeground3 }}># Añadir Outlook como data source</div>
<div>pac code add-data-source -a shared_outlook \</div>
<div style={{ paddingLeft: 24 }}>-c 4839c34829284206bf6a11d4ce577491</div>
<div style={{ paddingLeft: 24 }}>-c &lt;your-connection-id&gt;</div>
<br />
<div style={{ color: tokens.colorNeutralForeground3 }}># Añadir Dataverse (tabular)</div>
<div>pac code add-data-source -a shared_commondataservice \</div>

View File

@@ -1,148 +1,139 @@
import type { Email, UserProfile } from '../types';
import { Outlook_comService } from '../generated';
import type { Outlook_comModel } from '../generated';
import type { Email } from '../types';
import { FOLDER_PATH_MAP } from '../types';
// NOTE: In a real code app, these would be injected via the Power Platform
// client library (@microsoft/power-apps-code-solutions or similar).
// The client exposes typed services like Office365UsersService and
// OutlookService that wrap the connector calls.
//
// For this demo, we simulate the connector responses with mock data.
// To wire up real connectors:
// 1. Create connections in make.powerapps.com (e.g. "shared_office365", "shared_outlook")
// 2. Run: pac code add-data-source -a <apiName> -c <connectionId>
// 3. The generator creates typed Model and Service files automatically
//
// Real service usage would look like:
//
// import { OutlookService } from './services/OutlookService';
// const outlook = new OutlookService();
// const emails = await outlook.getMessages('inbox');
/**
* Adapter around the auto-generated Outlook_comService.
* Maps the Power Platform connector responses to the UI's Email type.
*/
const MOCK_USER: UserProfile = {
displayName: 'Lago',
email: 'jose@lago.dev',
};
const REVERSE_FOLDER_MAP: Record<string, Email['folder']> = Object.fromEntries(
Object.entries(FOLDER_PATH_MAP).map(([k, v]) => [v, k as Email['folder']])
) as Record<string, Email['folder']>;
const MOCK_EMAILS: Email[] = [
{
id: '1',
from: 'jose@lago.dev',
fromName: 'Lago',
to: 'lago@example.com',
subject: 'Sprint planning — Thursday 10:00',
body: 'Hola,\n\nVamos a planificar el sprint del mes que viene el jueves a las 10:00. Necesito que reviseis los temas pendientes del backlog.\n\nUn saludo',
receivedAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
isRead: false,
folder: 'inbox',
},
{
id: '2',
from: 'maria@partner.com',
fromName: 'María García',
to: 'lago@example.com',
subject: 'RE: Presupuesto proyecto digital 2026',
body: 'Hola Lago,\n\nGracias por la información. He revisado el presupuesto y parece correcto. Adjunto la propuesta final.\n\n¿Podemos hablar mañana por teléfono?\n\nSaludos',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
isRead: false,
folder: 'inbox',
},
{
id: '3',
from: 'devops@company.com',
fromName: 'DevOps Team',
to: 'lago@example.com',
subject: '[Alert] Production deployment failed — pipeline #4821',
body: 'Pipeline #4821 failed at stage: build\n\nError: npm ERR! code ETARGET\nnpm ERR! notarget No valid target for react@19.0.1\n\nView logs: https://devops.company.com/pipelines/4821',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
isRead: true,
folder: 'inbox',
},
{
id: '4',
from: 'lago@example.com',
fromName: 'Lago',
to: 'equipo@company.com',
subject: 'Resumen semanal — Power Platform',
body: 'Equipo,\n\nAquí va el resumen de lo hecho esta semana:\n\n• Demo de code apps publicada en el blog\n• Connector de Outlook integrado\n• Migración del backlog a Dataverse completada\n\nPara la semana que viene:\n• Terminar la integración con Teams\n• Revisar los permisos de producción\n\nSaludos',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
isRead: true,
folder: 'sent',
},
{
id: '5',
from: 'newsletter@techweekly.com',
fromName: 'Tech Weekly',
to: 'lago@example.com',
subject: 'This Week in AI & Power Platform — Issue #47',
body: 'Top stories this week:\n\n1. Microsoft announces GPT-5 integration for Copilot Studio\n2. Power Apps code apps reach 1,400+ connectors milestone\n3. Dataverse 2026 Wave 1 features now generally available\n\nRead more inside...',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
isRead: true,
folder: 'inbox',
},
{
id: '6',
from: 'noreply@linkedin.com',
fromName: 'LinkedIn',
to: 'lago@example.com',
subject: '5 people viewed your profile this week',
body: 'Your profile got noticed!\n\n5 people in Austria viewed your profile in the past week, including recruiters and hiring managers.\n\nView who they are →',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 72).toISOString(),
isRead: true,
folder: 'inbox',
},
{
id: '7',
from: 'lago@example.com',
fromName: 'Lago',
to: 'draft',
subject: 'Borrador: Propuesta reunión trimestral',
body: 'Borrador de la propuesta para la reunión trimestral con dirección...',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString(),
isRead: true,
folder: 'drafts',
},
];
/** Extract a display name from "Name <email>" or just "email" */
function parseSender(raw?: string): { email: string; name: string } {
if (!raw) return { email: '', name: '(desconocido)' };
// Some connectors return "Display Name <user@example.com>"
const match = raw.match(/^(.+?)\s*<(.+)>$/);
if (match) return { name: match[1].trim(), email: match[2].trim() };
// Otherwise it's just an email address
const namePart = raw.split('@')[0].replace(/[._-]/g, ' ');
return { email: raw, name: namePart.charAt(0).toUpperCase() + namePart.slice(1) };
}
function mapMessage(
msg: Outlook_comModel.ClientReceiveMessageStringEnums,
folderHint: Email['folder'],
): Email {
const sender = parseSender(msg.From);
return {
id: msg.Id ?? '',
from: sender.email,
fromName: sender.name,
to: msg.To ?? '',
subject: msg.Subject ?? '(Sin asunto)',
body: msg.Body ?? '',
bodyPreview: msg.BodyPreview ?? msg.Body?.substring(0, 100) ?? '',
receivedAt: msg.DateTimeReceived ?? new Date().toISOString(),
isRead: msg.IsRead ?? true,
isHtml: msg.IsHtml ?? false,
hasAttachments: msg.HasAttachment ?? false,
importance: msg.Importance ?? 'Normal',
folder: folderHint,
};
}
export class OutlookService {
private delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async getProfile(): Promise<UserProfile> {
await this.delay(300);
return MOCK_USER;
}
async getMessages(folder: string = 'inbox'): Promise<Email[]> {
await this.delay(600);
return MOCK_EMAILS.filter((e) => e.folder === folder);
/**
* Fetch emails from a given folder.
* Uses GetEmails (v1) which returns ClientReceiveMessageStringEnums[].
*/
async getMessages(folder: string = 'inbox', searchQuery?: string): Promise<Email[]> {
const folderPath = FOLDER_PATH_MAP[folder] ?? 'Inbox';
const uiFolder = (REVERSE_FOLDER_MAP[folderPath] ?? 'inbox') as Email['folder'];
const result = await Outlook_comService.GetEmails(
folderPath,
/* fetchOnlyUnread */ undefined,
/* includeAttachments */ false,
searchQuery,
/* top */ 50,
);
const messages = result.data ?? [];
return messages.map((m: Outlook_comModel.ClientReceiveMessageStringEnums) => mapMessage(m, uiFolder));
}
/**
* Get a single email by id.
*/
async getMessage(id: string): Promise<Email | undefined> {
await this.delay(200);
return MOCK_EMAILS.find((e) => e.id === id);
}
async sendMessage(email: Partial<Email>): Promise<Email> {
await this.delay(800);
const newEmail: Email = {
id: Date.now().toString(),
from: MOCK_USER.email,
fromName: MOCK_USER.displayName,
to: email.to || '',
subject: email.subject || '(Sin asunto)',
body: email.body || '',
receivedAt: new Date().toISOString(),
isRead: true,
folder: 'sent',
const result = await Outlook_comService.GetEmail(id, false);
if (!result.data) return undefined;
const msg = result.data;
const sender = parseSender(msg.From);
return {
id: msg.Id ?? '',
from: sender.email,
fromName: sender.name,
to: msg.To ?? '',
subject: msg.Subject ?? '(Sin asunto)',
body: msg.Body ?? '',
bodyPreview: msg.BodyPreview ?? '',
receivedAt: msg.DateTimeReceived ?? new Date().toISOString(),
isRead: msg.IsRead ?? true,
isHtml: msg.IsHtml ?? false,
hasAttachments: msg.HasAttachment ?? false,
importance: 'Normal',
folder: 'inbox',
};
MOCK_EMAILS.push(newEmail);
return newEmail;
}
/**
* Send an email via the V2 HTML endpoint.
*/
async sendMessage(params: { to: string; subject: string; body: string }): Promise<void> {
await Outlook_comService.SendEmailV2({
To: params.to,
Subject: params.subject,
Body: params.body,
});
}
/**
* Mark an email as read.
*/
async markAsRead(id: string): Promise<void> {
await this.delay(100);
const email = MOCK_EMAILS.find((e) => e.id === id);
if (email) email.isRead = true;
await Outlook_comService.MarkAsRead(id);
}
/**
* Delete an email.
*/
async deleteEmail(id: string): Promise<void> {
await Outlook_comService.DeleteEmail(id);
}
/**
* Reply to an email (HTML).
*/
async replyTo(messageId: string, body: string, replyAll: boolean = false): Promise<void> {
await Outlook_comService.ReplyToV3(messageId, {
Body: body,
ReplyAll: replyAll,
});
}
/**
* Forward an email.
*/
async forward(messageId: string, to: string, body: string): Promise<void> {
await Outlook_comService.ForwardEmail(messageId, {
ToRecipients: to,
Comment: body,
});
}
}

View File

@@ -0,0 +1,21 @@
/**
* Local development shim for @microsoft/power-apps/data.
*
* In production, the Power Platform runtime provides this module.
* During local dev, this shim logs calls and returns empty results
* so the app can render without the real connector backend.
*/
export function getClient(_dataSourcesInfo: unknown) {
return {
async executeAsync<_TParams, TResult>(request: {
connectorOperation: { operationName: string };
}): Promise<{ data?: TResult; error?: { message: string } }> {
console.warn(
`[power-apps shim] ${request.connectorOperation.operationName} called — ` +
`returning empty result (local dev mode, no Power Platform runtime).`
);
return { data: undefined };
},
};
}

View File

@@ -5,8 +5,12 @@ export interface Email {
to: string;
subject: string;
body: string;
bodyPreview: string;
receivedAt: string;
isRead: boolean;
isHtml: boolean;
hasAttachments: boolean;
importance: 'Low' | 'Normal' | 'High';
folder: 'inbox' | 'sent' | 'drafts' | 'trash';
}
@@ -17,8 +21,10 @@ export interface Folder {
unreadCount: number;
}
export interface UserProfile {
displayName: string;
email: string;
avatar?: string;
}
/** Maps UI folder ids to Outlook folderPath values */
export const FOLDER_PATH_MAP: Record<string, string> = {
inbox: 'Inbox',
sent: 'SentItems',
drafts: 'Drafts',
trash: 'DeletedItems',
};

29
src/types/power-apps.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/**
* Type stub for @microsoft/power-apps/data.
*
* This module is provided by the Power Platform runtime at execution time.
* During local development / tsc type-checking it is not installed,
* so we declare just enough types for the generated service to compile.
*/
declare module '@microsoft/power-apps/data' {
export interface IOperationResult<T> {
data?: T;
error?: { message: string; code?: string };
}
export interface ConnectorOperation {
tableName: string;
operationName: string;
parameters?: unknown;
}
export interface ExecuteRequest {
connectorOperation: ConnectorOperation;
}
export interface IClient {
executeAsync<TParams, TResult>(request: ExecuteRequest): Promise<IOperationResult<TResult>>;
}
export function getClient(dataSourcesInfo: unknown): IClient;
}