@huloglobal/vendure-plugin-email-tracking 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +34 -0
  3. package/README.md +129 -0
  4. package/dist/email-log.entity.d.ts +57 -0
  5. package/dist/email-log.entity.d.ts.map +1 -0
  6. package/dist/email-log.entity.js +147 -0
  7. package/dist/email-log.entity.js.map +1 -0
  8. package/dist/email-tracking.controller.d.ts +51 -0
  9. package/dist/email-tracking.controller.d.ts.map +1 -0
  10. package/dist/email-tracking.controller.js +260 -0
  11. package/dist/email-tracking.controller.js.map +1 -0
  12. package/dist/email-tracking.service.d.ts +48 -0
  13. package/dist/email-tracking.service.d.ts.map +1 -0
  14. package/dist/email-tracking.service.js +196 -0
  15. package/dist/email-tracking.service.js.map +1 -0
  16. package/dist/index.d.ts +16 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +22 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/options.d.ts +43 -0
  21. package/dist/options.d.ts.map +1 -0
  22. package/dist/options.js +52 -0
  23. package/dist/options.js.map +1 -0
  24. package/dist/plugin.d.ts +53 -0
  25. package/dist/plugin.d.ts.map +1 -0
  26. package/dist/plugin.js +118 -0
  27. package/dist/plugin.js.map +1 -0
  28. package/dist/proxy-headers.d.ts +37 -0
  29. package/dist/proxy-headers.d.ts.map +1 -0
  30. package/dist/proxy-headers.js +81 -0
  31. package/dist/proxy-headers.js.map +1 -0
  32. package/dist/tracking-email-sender.d.ts +22 -0
  33. package/dist/tracking-email-sender.d.ts.map +1 -0
  34. package/dist/tracking-email-sender.js +117 -0
  35. package/dist/tracking-email-sender.js.map +1 -0
  36. package/package.json +53 -0
  37. package/ui/components/email-log.component.ts +372 -0
  38. package/ui/email-log-nav.module.ts +24 -0
  39. package/ui/email-log.module.ts +17 -0
@@ -0,0 +1,372 @@
1
+ import { Component, OnInit, ChangeDetectorRef } from '@angular/core';
2
+ import { ActivatedRoute } from '@angular/router';
3
+ import { HttpClient } from '@angular/common/http';
4
+ import { NotificationService } from '@vendure/admin-ui/core';
5
+
6
+ interface EmailRow {
7
+ id: number;
8
+ createdAt: string;
9
+ type: string;
10
+ recipient: string;
11
+ subject: string;
12
+ status: string;
13
+ customerId: number | null;
14
+ orderId: number | null;
15
+ orderCode: string | null;
16
+ invoiceId: number | null;
17
+ applicationId: number | null;
18
+ channelId: number;
19
+ openCount: number;
20
+ firstOpenedAt: string | null;
21
+ lastOpenedAt: string | null;
22
+ clickCount: number;
23
+ firstClickedAt: string | null;
24
+ smtpResponse: string | null;
25
+ errorMessage: string | null;
26
+ }
27
+
28
+ interface EmailDetail extends EmailRow {
29
+ fromAddress: string | null;
30
+ bcc: string | null;
31
+ replyTo: string | null;
32
+ context: string | null;
33
+ smtpMessageId: string | null;
34
+ firstOpenIp: string | null;
35
+ firstOpenUserAgent: string | null;
36
+ clicks: Array<{ url: string; ts: string; ip: string | null; ua: string | null }>;
37
+ }
38
+
39
+ @Component({
40
+ selector: 'ees-email-log',
41
+ standalone: false,
42
+ template: `
43
+ <vdr-page-block>
44
+ <vdr-action-bar>
45
+ <vdr-ab-left><h2>Email Log</h2></vdr-ab-left>
46
+ <vdr-ab-right>
47
+ <button class="btn btn-link" (click)="load()" [disabled]="loading">
48
+ <clr-icon shape="refresh"></clr-icon> Refresh
49
+ </button>
50
+ </vdr-ab-right>
51
+ </vdr-action-bar>
52
+ </vdr-page-block>
53
+
54
+ <vdr-page-block>
55
+ <div class="summary-row">
56
+ <div class="summary-card" [class.active]="filterStatus===''" (click)="setStatus('')">
57
+ <div class="num" style="color:#1d4ed8">{{ totalAll() }}</div>
58
+ <div class="lbl">Last {{ summary.fromDays }} days</div>
59
+ </div>
60
+ <div class="summary-card" [class.active]="filterStatus==='sent'" (click)="setStatus('sent')">
61
+ <div class="num" style="color:#10b981">{{ summary.sent || 0 }}</div>
62
+ <div class="lbl">Sent</div>
63
+ </div>
64
+ <div class="summary-card" [class.active]="filterStatus==='failed'" (click)="setStatus('failed')">
65
+ <div class="num" style="color:#ef4444">{{ summary.failed || 0 }}</div>
66
+ <div class="lbl">Failed</div>
67
+ </div>
68
+ <div class="summary-card" [class.active]="filterStatus==='deferred'" (click)="setStatus('deferred')">
69
+ <div class="num" style="color:#f59e0b">{{ summary.deferred || 0 }}</div>
70
+ <div class="lbl">Deferred</div>
71
+ </div>
72
+ <div class="summary-card" [class.active]="filterStatus==='bounced'" (click)="setStatus('bounced')">
73
+ <div class="num" style="color:#9333ea">{{ summary.bounced || 0 }}</div>
74
+ <div class="lbl">Bounced</div>
75
+ </div>
76
+ <div class="summary-card">
77
+ <div class="num" style="color:#0369a1">{{ summary.opens || 0 }}</div>
78
+ <div class="lbl">Opens</div>
79
+ <div class="sub">{{ summary.clicks || 0 }} clicks</div>
80
+ </div>
81
+ </div>
82
+ </vdr-page-block>
83
+
84
+ <vdr-page-block>
85
+ <div class="card">
86
+ <div class="card-block">
87
+ <div class="filters">
88
+ <input class="form-input" placeholder="Filter recipient…" [(ngModel)]="filterRecipient" (keyup.enter)="load()">
89
+ <input class="form-input" placeholder="Filter type…" [(ngModel)]="filterType" (keyup.enter)="load()">
90
+ <input class="form-input" placeholder="Customer ID" [(ngModel)]="filterCustomerId" (keyup.enter)="load()">
91
+ <input class="form-input" placeholder="Order code" [(ngModel)]="filterOrderCode" (keyup.enter)="load()">
92
+ <button class="btn btn-secondary" (click)="load()">Apply</button>
93
+ <button class="btn btn-link" (click)="clearFilters()">Clear</button>
94
+ </div>
95
+
96
+ <div *ngIf="loading" style="padding:30px;text-align:center;color:var(--color-component-color-300)">Loading…</div>
97
+ <div *ngIf="!loading && rows.length === 0" style="padding:30px;text-align:center;color:var(--color-component-color-300)">
98
+ No emails match this view.
99
+ </div>
100
+
101
+ <table class="table table-compact" *ngIf="rows.length > 0">
102
+ <thead>
103
+ <tr>
104
+ <th>#</th>
105
+ <th>Sent</th>
106
+ <th>Type</th>
107
+ <th>Recipient</th>
108
+ <th>Subject</th>
109
+ <th>Status</th>
110
+ <th>Opens</th>
111
+ <th>Clicks</th>
112
+ <th>Linked to</th>
113
+ <th></th>
114
+ </tr>
115
+ </thead>
116
+ <tbody>
117
+ <ng-container *ngFor="let r of rows">
118
+ <tr>
119
+ <td><strong>#{{ r.id }}</strong></td>
120
+ <td>{{ r.createdAt | date:'short' }}</td>
121
+ <td><span class="pill type-pill">{{ r.type }}</span></td>
122
+ <td>
123
+ <a [href]="'mailto:' + r.recipient">{{ r.recipient }}</a>
124
+ </td>
125
+ <td style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" [title]="r.subject">{{ r.subject }}</td>
126
+ <td>
127
+ <span class="pill" [ngClass]="'status-' + r.status">{{ r.status }}</span>
128
+ </td>
129
+ <td>
130
+ <span [class.muted]="r.openCount === 0" [style.color]="r.openCount > 0 ? '#10b981' : null">{{ r.openCount }}</span>
131
+ <div class="help-text" *ngIf="r.firstOpenedAt">{{ r.firstOpenedAt | date:'short' }}</div>
132
+ </td>
133
+ <td>
134
+ <span [class.muted]="r.clickCount === 0" [style.color]="r.clickCount > 0 ? '#0369a1' : null">{{ r.clickCount }}</span>
135
+ </td>
136
+ <td>
137
+ <a *ngIf="r.orderId" [routerLink]="['/orders', r.orderId]">{{ r.orderCode || r.orderId }}</a>
138
+ <a *ngIf="r.customerId && !r.orderId" [routerLink]="['/customers', r.customerId]">cust #{{ r.customerId }}</a>
139
+ <span *ngIf="!r.orderId && !r.customerId" class="help-text">—</span>
140
+ </td>
141
+ <td>
142
+ <button class="btn btn-sm btn-link" (click)="open(r.id)">
143
+ <clr-icon shape="eye"></clr-icon> Details
144
+ </button>
145
+ </td>
146
+ </tr>
147
+ <tr *ngIf="expandedId === r.id && detail" class="detail-row">
148
+ <td colspan="10">
149
+ <div class="detail-grid">
150
+ <div>
151
+ <div class="lbl">From</div><div>{{ detail.fromAddress }}</div>
152
+ <div class="lbl">To</div><div>{{ detail.recipient }}</div>
153
+ <div class="lbl" *ngIf="detail.bcc">BCC</div><div *ngIf="detail.bcc">{{ detail.bcc }}</div>
154
+ <div class="lbl">Subject</div><div>{{ detail.subject }}</div>
155
+ <div class="lbl">Context</div><div>{{ detail.context || '—' }}</div>
156
+ </div>
157
+ <div>
158
+ <div class="lbl">SMTP message id</div>
159
+ <div style="font-family:monospace;font-size:11px;word-break:break-all">{{ detail.smtpMessageId || '—' }}</div>
160
+ <div class="lbl">SMTP response</div>
161
+ <div style="font-family:monospace;font-size:11px;word-break:break-all">{{ detail.smtpResponse || '—' }}</div>
162
+ <div class="lbl" *ngIf="detail.errorMessage">Error</div>
163
+ <div *ngIf="detail.errorMessage" style="color:#ef4444">{{ detail.errorMessage }}</div>
164
+ </div>
165
+ <div>
166
+ <div class="lbl">Opens</div>
167
+ <div>{{ detail.openCount }} ({{ detail.firstOpenedAt | date:'short' }} → {{ detail.lastOpenedAt | date:'short' }})</div>
168
+ <div class="lbl" *ngIf="detail.firstOpenIp">First open IP</div>
169
+ <div *ngIf="detail.firstOpenIp" style="font-family:monospace;font-size:11px">{{ detail.firstOpenIp }}</div>
170
+ <div class="lbl" *ngIf="detail.firstOpenUserAgent">First open UA</div>
171
+ <div *ngIf="detail.firstOpenUserAgent" style="font-size:11px;color:var(--color-component-color-300)">{{ detail.firstOpenUserAgent }}</div>
172
+ </div>
173
+ </div>
174
+ <h5 style="margin-top:15px" *ngIf="detail.clicks.length > 0">Clicks ({{ detail.clickCount }})</h5>
175
+ <table class="table table-compact" *ngIf="detail.clicks.length > 0">
176
+ <thead><tr><th>Time</th><th>URL</th><th>IP</th><th>UA</th></tr></thead>
177
+ <tbody>
178
+ <tr *ngFor="let c of detail.clicks">
179
+ <td>{{ c.ts | date:'short' }}</td>
180
+ <td style="font-family:monospace;font-size:11px;word-break:break-all"><a [href]="c.url" target="_blank">{{ c.url }}</a></td>
181
+ <td>{{ c.ip }}</td>
182
+ <td style="font-size:11px;color:var(--color-component-color-300)">{{ c.ua }}</td>
183
+ </tr>
184
+ </tbody>
185
+ </table>
186
+ </td>
187
+ </tr>
188
+ </ng-container>
189
+ </tbody>
190
+ </table>
191
+
192
+ <div class="pager" *ngIf="total > take">
193
+ <button class="btn btn-sm" (click)="prevPage()" [disabled]="skip === 0">‹ Prev</button>
194
+ <span style="margin:0 12px;font-size:12px">Showing {{ skip + 1 }}–{{ skip + rows.length }} of {{ total }}</span>
195
+ <button class="btn btn-sm" (click)="nextPage()" [disabled]="skip + take >= total">Next ›</button>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </vdr-page-block>
200
+ `,
201
+ styles: [`
202
+ :host { color: var(--color-text-100, inherit); }
203
+ .summary-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
204
+ .summary-card {
205
+ flex: 1; min-width: 130px; padding: 14px 18px;
206
+ border: 1px solid var(--color-component-border-200);
207
+ border-radius: 6px;
208
+ background: var(--color-component-bg-100);
209
+ color: var(--color-text-100, inherit);
210
+ cursor: pointer; transition: border-color .15s, box-shadow .15s;
211
+ }
212
+ .summary-card:hover { border-color: var(--color-primary-500, #1d4ed8); }
213
+ .summary-card.active {
214
+ border-color: var(--color-primary-500, #1d4ed8);
215
+ box-shadow: 0 0 0 2px rgba(29,78,216,.18);
216
+ }
217
+ .summary-card .num { font-size: 24px; font-weight: 700; line-height: 1.2; }
218
+ .summary-card .lbl { font-size: 11px; color: var(--color-component-color-300); margin-top: 2px; }
219
+ .summary-card .sub { font-size: 10px; color: var(--color-component-color-300); margin-top: 2px; }
220
+
221
+ .filters { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
222
+ .filters .form-input {
223
+ padding: 4px 8px; height: 32px;
224
+ border: 1px solid var(--color-component-border-200);
225
+ background: var(--color-component-bg-100);
226
+ color: var(--color-text-100, inherit);
227
+ border-radius: 4px; min-width: 160px;
228
+ }
229
+
230
+ .pill {
231
+ display: inline-block; padding: 2px 8px; border-radius: 10px;
232
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
233
+ color: #fff;
234
+ }
235
+ .type-pill {
236
+ background: var(--color-component-bg-200);
237
+ color: var(--color-text-100, inherit);
238
+ border: 1px solid var(--color-component-border-200);
239
+ }
240
+ .status-sent { background: #10b981; }
241
+ .status-failed { background: #ef4444; }
242
+ .status-deferred { background: #f59e0b; }
243
+ .status-bounced { background: #9333ea; }
244
+ .status-complained { background: #db2777; }
245
+
246
+ .detail-row > td {
247
+ background: var(--color-component-bg-200);
248
+ color: var(--color-text-100, inherit);
249
+ padding: 18px;
250
+ border-top: 1px solid var(--color-component-border-200);
251
+ }
252
+ .detail-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
253
+ .detail-grid .lbl {
254
+ font-size: 11px;
255
+ color: var(--color-component-color-300);
256
+ text-transform: uppercase; margin-top: 6px;
257
+ }
258
+ .pager {
259
+ display: flex; align-items: center; justify-content: flex-end;
260
+ padding: 10px 0;
261
+ color: var(--color-text-100, inherit);
262
+ }
263
+ .help-text { color: var(--color-component-color-300); font-size: 11px; }
264
+ .muted { color: var(--color-component-color-300); }
265
+ `],
266
+ })
267
+ export class EmailLogComponent implements OnInit {
268
+ rows: EmailRow[] = [];
269
+ total = 0;
270
+ take = 100;
271
+ skip = 0;
272
+ loading = false;
273
+
274
+ filterStatus = '';
275
+ filterRecipient = '';
276
+ filterType = '';
277
+ filterCustomerId = '';
278
+ filterOrderCode = '';
279
+ /** When the page is mounted with a ?customerId= query param, lock to that
280
+ * customer (used by the per-customer Emails tab). */
281
+ lockedCustomerId: string | null = null;
282
+
283
+ summary: any = { sent: 0, failed: 0, deferred: 0, bounced: 0, opens: 0, clicks: 0, fromDays: 30 };
284
+
285
+ expandedId: number | null = null;
286
+ detail: EmailDetail | null = null;
287
+
288
+ constructor(
289
+ private http: HttpClient,
290
+ private notify: NotificationService,
291
+ private route: ActivatedRoute,
292
+ private cdr: ChangeDetectorRef,
293
+ ) {}
294
+
295
+ ngOnInit() {
296
+ const cId = this.route.snapshot.queryParamMap.get('customerId');
297
+ if (cId) {
298
+ this.lockedCustomerId = cId;
299
+ this.filterCustomerId = cId;
300
+ }
301
+ const oCode = this.route.snapshot.queryParamMap.get('orderCode');
302
+ if (oCode) this.filterOrderCode = oCode;
303
+ this.load();
304
+ }
305
+
306
+ totalAll(): number {
307
+ return (this.summary.sent || 0) + (this.summary.failed || 0) + (this.summary.deferred || 0)
308
+ + (this.summary.bounced || 0) + (this.summary.complained || 0);
309
+ }
310
+
311
+ setStatus(s: string) {
312
+ this.filterStatus = s;
313
+ this.skip = 0;
314
+ this.load();
315
+ }
316
+
317
+ clearFilters() {
318
+ this.filterStatus = '';
319
+ this.filterRecipient = '';
320
+ this.filterType = '';
321
+ this.filterCustomerId = this.lockedCustomerId || '';
322
+ this.filterOrderCode = '';
323
+ this.skip = 0;
324
+ this.load();
325
+ }
326
+
327
+ private buildParams(): string {
328
+ const p: string[] = [`take=${this.take}`, `skip=${this.skip}`];
329
+ if (this.filterStatus) p.push(`status=${encodeURIComponent(this.filterStatus)}`);
330
+ if (this.filterRecipient) p.push(`recipient=${encodeURIComponent(this.filterRecipient)}`);
331
+ if (this.filterType) p.push(`type=${encodeURIComponent(this.filterType)}`);
332
+ if (this.filterCustomerId) p.push(`customerId=${encodeURIComponent(this.filterCustomerId)}`);
333
+ if (this.filterOrderCode) p.push(`orderCode=${encodeURIComponent(this.filterOrderCode)}`);
334
+ return p.join('&');
335
+ }
336
+
337
+ load() {
338
+ this.loading = true;
339
+ Promise.all([
340
+ this.http.get<any>(`/email-track/log?${this.buildParams()}`).toPromise(),
341
+ this.http.get<any>('/email-track/log/summary?fromDays=30').toPromise(),
342
+ ]).then(([list, summary]) => {
343
+ this.rows = list?.items || [];
344
+ this.total = list?.total || 0;
345
+ this.summary = summary || this.summary;
346
+ this.loading = false;
347
+ this.expandedId = null;
348
+ this.detail = null;
349
+ this.cdr.markForCheck();
350
+ }).catch(() => {
351
+ this.loading = false;
352
+ this.notify.error('Failed to load email log');
353
+ });
354
+ }
355
+
356
+ open(id: number) {
357
+ if (this.expandedId === id) {
358
+ this.expandedId = null;
359
+ this.detail = null;
360
+ return;
361
+ }
362
+ this.expandedId = id;
363
+ this.detail = null;
364
+ this.http.get<EmailDetail>(`/email-track/log/${id}`).subscribe({
365
+ next: (d) => { this.detail = d; this.cdr.markForCheck(); },
366
+ error: () => this.notify.error('Failed to load email detail'),
367
+ });
368
+ }
369
+
370
+ nextPage() { this.skip += this.take; this.load(); }
371
+ prevPage() { this.skip = Math.max(0, this.skip - this.take); this.load(); }
372
+ }
@@ -0,0 +1,24 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { SharedModule, addNavMenuItem } from '@vendure/admin-ui/core';
3
+
4
+ /**
5
+ * Registers the "Email Log" entry in the admin nav. Plugged into the
6
+ * existing "Customers" section so account-handling staff find it where
7
+ * they expect.
8
+ */
9
+ @NgModule({
10
+ imports: [SharedModule],
11
+ providers: [
12
+ addNavMenuItem(
13
+ {
14
+ id: 'hulo-email-log',
15
+ label: 'Email Log',
16
+ routerLink: ['/extensions/email-log'],
17
+ icon: 'envelope',
18
+ requiresPermission: 'ReadCustomer',
19
+ },
20
+ 'customers',
21
+ ),
22
+ ],
23
+ })
24
+ export class EmailLogNavModule {}
@@ -0,0 +1,17 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { RouterModule } from '@angular/router';
3
+ import { SharedModule } from '@vendure/admin-ui/core';
4
+ import { FormsModule } from '@angular/forms';
5
+ import { HttpClientModule } from '@angular/common/http';
6
+ import { EmailLogComponent } from './components/email-log.component';
7
+
8
+ @NgModule({
9
+ imports: [
10
+ SharedModule, FormsModule, HttpClientModule,
11
+ RouterModule.forChild([
12
+ { path: '', pathMatch: 'full', component: EmailLogComponent, data: { breadcrumb: 'Email Log' } },
13
+ ]),
14
+ ],
15
+ declarations: [EmailLogComponent],
16
+ })
17
+ export class EmailLogModule {}