File Coverage

blib/lib/Image/ExifTool/TNEF.pm
Criterion Covered Total %
statement 125 146 85.6
branch 52 90 57.7
condition 16 30 53.3
subroutine 8 8 100.0
pod 0 3 0.0
total 201 277 72.5


line stmt bran cond sub pod time code
1             #------------------------------------------------------------------------------
2             # File: TNEF.pm
3             #
4             # Description: Read TNEF meta information
5             #
6             # Revisions: 2025-07-08 - P. Harvey Created
7             #
8             # References: 1) https://officeprotocoldoc.z19.web.core.windows.net/files/MS-OXTNEF/%5bMS-OXTNEF%5d.pdf
9             # 2) https://officeprotocoldoc.z19.web.core.windows.net/files/MS-OXCMSG/%5bMS-OXCMSG%5d.pdf
10             # 3) https://msopenspecs.azureedge.net/files/MS-OXPROPS/%5bMS-OXPROPS%5d.pdf
11             # 4) https://officeprotocoldoc.z19.web.core.windows.net/files/MS-OXCDATA/%5bMS-OXCDATA%5d.pdf
12             # 5) https://github.com/echo-devim/pyjacktrick/blob/main/mapi_constants.py
13             #------------------------------------------------------------------------------
14              
15             package Image::ExifTool::TNEF;
16              
17 1     1   5811 use strict;
  1         1  
  1         34  
18 1     1   4 use vars qw($VERSION);
  1         2  
  1         36  
19 1     1   4 use Image::ExifTool qw(:DataAccess :Utils);
  1         1  
  1         230  
20 1     1   500 use Image::ExifTool::ASF;
  1         8  
  1         78  
21 1     1   887 use Image::ExifTool::Microsoft;
  1         5  
  1         1965  
22              
23             $VERSION = '1.00';
24              
25             sub ProcessProps($$$);
26              
27             # TNEF property types
28             my %propType = (
29             0x01 => 'null',
30             0x02 => 'int16s',
31             0x03 => 'int32s',
32             0x04 => 'float',
33             0x05 => 'double',
34             0x06 => 'int64s', # (currency / 10000)
35             0x07 => 'double', # (days since Dec 30, 1899)
36             0x0a => 'int32s', # (error code)
37             0x0b => 'int16s', # (boolean)
38             0x0d => 'undef', # (object)
39             0x14 => 'int64s',
40             0x1e => 'string', # (with terminating null)
41             0x1f => 'Unicode',# (with terminating null)
42             0x40 => 'int64u', # (time in 100 ns since 1601)
43             0x48 => 'GUID', # (16 bytes)
44             0x102 => 'undef', # (blob)
45             );
46              
47             # byte count for non-integer fixed-size formats
48             my %fmtSize = (
49             null => 0,
50             float => 4,
51             double => 8,
52             GUID => 16,
53             );
54              
55             my %dateInfo = (
56             Format => 'date',
57             Groups => { 2 => 'Time' },
58             PrintConv => '$self->ConvertDateTime($val)',
59             );
60              
61             %Image::ExifTool::TNEF::Main = (
62             GROUPS => { 0 => 'File', 1 => 'File', 2 => 'Other' },
63             VARS => { NO_LOOKUP => 1 },
64             NOTES => q{
65             Information extracted from Transport Neutral Encapsulation Format (TNEF)
66             files (eg. winmail.dat). But note that the exiftool application doesn't
67             process files with a .DAT extension by default when a directory name is
68             given, so in this case either specify the .DAT file(s) by name or add
69             C<-ext+ dat> to the command.
70             },
71             0x069007 => {
72             Name => 'CodePage',
73             Format => 'int32u',
74             SeparateTable => 'Microsoft CodePage',
75             # (ignore secondary code page)
76             RawConv => '$val=~s/ .*//;$$self{Charset} = $charsetName{"cp$val"}; $val',
77             PrintConv => \%Image::ExifTool::Microsoft::codePage,
78             },
79             0x089006 => {
80             Name => 'TNEFVersion',
81             Format => 'int8u',
82             ValueConv => 'my @a = reverse split " ", $val; "@a"',
83             PrintConv => '$val =~ tr/ /./; $val',
84             },
85             0x078008 => 'MessageClass',
86             0x008000 => 'From',
87             0x018004 => 'Subject',
88             0x038005 => { Name => 'SentDate', %dateInfo },
89             0x038006 => { Name => 'ReceivedDate', %dateInfo },
90             0x068007 => 'MessageStatus',
91             0x018009 => 'MessageID',
92             0x02800C => 'MessageBody',
93             0x04800D => {
94             Name => 'Priority',
95             Format => 'int16u', # (contrary to documentation which says int32u)
96             PrintConv => {
97             0 => 'Low',
98             1 => 'Normal',
99             2 => 'High',
100             },
101             },
102             0x038020 => { Name => 'MessageModifyDate', %dateInfo }, # (unclear what this really means)
103             0x069003 => {
104             Name => 'MessageProps',
105             SubDirectory => { TagTable => 'Image::ExifTool::TNEF::MsgProps' },
106             },
107             0x069004 => 'RecipientTable',
108             0x070600 => 'OriginalMessageClass',
109             0x060000 => 'Owner',
110             0x060001 => 'SentFor',
111             0x060002 => 'Delegate',
112             0x030006 => { Name => 'StartDate', %dateInfo },
113             0x030007 => { Name => 'EndDate', %dateInfo },
114             0x050008 => 'OwnerAppointmentID',
115             0x040009 => 'ResponseRequested',
116             0x06800F => { Name => 'AttachData', Binary => 1 },
117             0x018010 => 'AttachTitle',
118             0x068011 => { Name => 'AttachMetaFile', Binary => 1 },
119             0x038012 => { Name => 'AttachCreateDate', %dateInfo },
120             0x038013 => { Name => 'AttachModifyDate', %dateInfo },
121             0x069001 => 'AttachTransportFilename',
122             0x069002 => { Name => 'AttachRenderingData', Binary => 1 }, # (start of attachment)
123             0x069005 => { # (end of attachment)
124             Name => 'AttachInfo',
125             SubDirectory => { TagTable => 'Image::ExifTool::TNEF::AttachInfo' },
126             },
127             );
128              
129             %Image::ExifTool::TNEF::MsgProps = (
130             GROUPS => { 0 => 'File', 1 => 'File', 2 => 'Other' },
131             PROCESS_PROC => \&ProcessProps,
132             TAG_PREFIX => 'MsgProps',
133             VARS => { LONG_TAGS => 0, NO_LOOKUP => 1 }, # (suppress "long tags" warning in BuildTagLookup)
134             0x0002 => 'AlternateRecipientAllowed',
135             0x0039 => { Name => 'ClientSubmitTime', %dateInfo },
136             0x0040 => 'ReceivedByName',
137             0x0044 => 'ReceivedRepresentingName',
138             0x004d => { Name => 'OriginalAuthorName', Groups => { 2 => 'Author' } },
139             0x0055 => { Name => 'OriginalDeliveryTime', %dateInfo },
140             0x0070 => 'Subject',
141             0x0075 => 'ReceivedByAddressType',
142             0x0076 => 'ReceivedByEmailAddress',
143             0x0077 => 'ReceivedRepresentingAddressType',
144             0x0078 => 'ReceivedRepresentingEmailAddress',
145             0x007f => { Name => 'CorrelationKey', RawConv => '$$val' },
146             0x0c1a => 'SenderName',
147             0x0c1d => { Name => 'SenderSearchKey', RawConv => 'ref $val ? $$val : $val' },
148             0x0e06 => { Name => 'MessageDeliveryTime', %dateInfo },
149             0x0e1d => 'NormalizedSubject',
150             0x0e28 => 'PrimarySendAccount',
151             0x0e29 => 'NextSendAccount',
152             0x0f02 => { Name => 'DeliveryOrRenewTime', %dateInfo }, #5
153             0x1000 => { Name => 'MessageBodyText', Binary => 1 },
154             0x1007 => 'SyncBodyCount',
155             0x1008 => 'SyncBodyData',
156             0x1009 => {
157             Name => 'MessageBodyRTF',
158             Notes => 'RTF message body, decompressed if necessary',
159             RawConv => '$$val', # (ValueConv won't convert a scalar ref, so convert to scalar here)
160             ValueConv => 'my $dat = Image::ExifTool::TNEF::DecompressRTF($self,$val); \$dat',
161             },
162             0x1013 => { Name => 'MessageBodyHTML', Binary => 1 },
163             0x1035 => 'InternetMessageID',
164             0x10f4 => 'Hidden',
165             0x10f6 => 'ReadOnly',
166             0x3007 => { Name => 'CreateDate', %dateInfo },
167             0x3008 => { Name => 'ModifyDate', %dateInfo },
168             0x3fde => 'InternetCodePage',
169             0x3ff1 => 'LocalUserID',
170             0x3ff8 => { Name => 'CreatorName', Groups => { 2 => 'Author' } },
171             0x3ffa => 'LastModifierName',
172             0x3ffd => 'MessageCodePage',
173             0x4076 => { Name => 'SpamConfidenceLevel' },
174             # named properties that look interesting
175             '00020329_Author' => {
176             Name => 'Author',
177             Groups => { 2 => 'Author' },
178             Notes => q{
179             tag ID's for named properties are constructed from the property namespace
180             GUID with the ending "-0000-0000-C000-000000000046" removed, followed by the
181             string or numerical ID in hex, separated by an underscore
182             },
183             }, # (NC)
184             '00020329_LastAuthor' => { Name => 'LastAuthor', Groups => { 2 => 'Author' } }, # (NC)
185             '00062004_0000801A' => 'HomeAddress', # (NC)
186             '00062004_000080DA' => 'HomeAddressCountryCode', # (NC)
187             '00062008_00008554' => 'AppVersion',
188             );
189              
190             # ref https://pkg.go.dev/github.com/axigenmessaging/tnef#section-readme
191             %Image::ExifTool::TNEF::AttachInfo = (
192             GROUPS => { 0 => 'File', 1 => 'File', 2 => 'Other' },
193             PROCESS_PROC => \&ProcessProps,
194             TAG_PREFIX => 'Attach',
195             0x0e20 => 'AttachSize',
196             0x0e21 => 'AttachNum',
197             0x0ff8 => { Name => 'MappingSignature', Unknown => 1 },
198             0x3001 => 'AttachFileName',
199             0x3703 => 'AttachFileExtension',
200             0x3701 => 'AttachBinary',
201             0x3705 => {
202             Name => 'AttachMethod',
203             PrintConv => {
204             0 => 'Attachment Created',
205             1 => 'AttachData', # (contrary to documentation which says the AttachBinary tag)
206             2 => 'AttachLongPathName (recipients with access)',
207             4 => 'AttachLongPathName',
208             5 => 'Embedded Message',
209             6 => 'AttachBinary (object)',
210             7 => 'AttachLongPathName (using AttachmentProviderType)',
211             },
212             },
213             0x3707 => 'AttachLongFileName',
214             0x3708 => 'AttachPathName',
215             0x370d => 'AttachLongPathName',
216             0x370e => 'AttachMIMEType',
217             0x7ffb => {
218             Name => 'ExceptionStartTime',
219             %dateInfo,
220             Unknown => 1, # (because these values don't make sense in my samples)
221             },
222             0x7ffc => { Name => 'ExceptionEndTime', Unknown => 1, %dateInfo },
223             );
224              
225             #------------------------------------------------------------------------------
226             # Decompress RTF text (ref https://metacpan.org/pod/Mail::Exchange::Message)
227             # Inputs: 0) ExifTool ref, 1) compressed RTF
228             # Returns: Decompressed RTF or '' on error
229             sub DecompressRTF($$)
230             {
231 1     1 0 2 my ($et, $cdat) = @_;
232 1 50       5 return '' unless length $cdat > 16;
233 1         3 my $comp = unpack('x8V', $cdat);
234              
235 1 50       5 if ($comp == 0x414c454D) {
    50          
236 0         0 return substr($cdat, 16);
237             } elsif ($comp != 0x75465a4c) {
238 0         0 $et->Warn(sprintf('Unknown RTF compression 0x%x', $comp));
239 0         0 return '';
240             }
241 1         2 my $dict = '{\rtf1\ansi\mac\deff0\deftab720{\fonttbl;}'.
242             '{\f0\fnil \froman \fswiss \fmodern '.
243             '\fscript \fdecor MS Sans SerifSymbolArialTimes'.
244             ' New RomanCourier{\colortbl\red0\green0\blue0'.
245             "\r\n".'\par \pard\plain\f0\fs20\b\i\u\tab\tx';
246 1         2 my $cpos = 16;
247 1         1 my $clen = length $cdat;
248 1         2 my $dpos = length $dict;
249 1         1 my $rtnVal = '';
250 1         3 while ($cpos < $clen) {
251 11         21 my $control = unpack('C', substr($cdat, $cpos++, 1));
252 11         19 my ($i, $j);
253 11   66     31 for ($i=0; $i<8 && $cpos<$clen; ++$i) {
254 84 100       150 if ($control & (1<<$i)) {
255 39 50       65 return $rtnVal if $cpos + 2 > $clen;
256 39         61 my $ref = unpack('n', substr($cdat, $cpos, 2));
257 39         44 $cpos += 2;
258 39         51 my $off = $ref >> 4;
259 39         48 my $len = ($ref & 0x0f) + 2;
260 39 100 66     140 return $rtnVal if $off == $dpos % 4096 or $off % 4096 >= length($dict);
261 38         78 for ($j=0; $j<$len; ++$j) {
262 126         167 my $ch = substr($dict, ($off++ % 4096), 1);
263 126         158 substr($dict, ($dpos++ % 4096), 1) = $ch;
264 126         294 $rtnVal .= $ch;
265             }
266             } else {
267 45         62 my $ch = substr($cdat, $cpos++, 1);
268 45         63 substr($dict, ($dpos++ % 4096), 1) = $ch;
269 45         123 $rtnVal .= $ch;
270             }
271             }
272             }
273 0         0 return $rtnVal;
274             }
275              
276             #------------------------------------------------------------------------------
277             # Process TNEF message properties
278             # Inputs: 0) ExifTool ref, 1) dirInfo ref, 2) tag table ref
279             # Returns: 1 on success
280             sub ProcessProps($$$)
281             {
282 1     1 0 2 my ($et, $dirInfo, $tagTbl) = @_;
283 1         3 my $dataPt = $$dirInfo{DataPt};
284 1         2 my $dataPos = $$dirInfo{DataPos};
285 1         5 my $dirLen = length $$dataPt;
286 1 50       3 return 0 unless $dirLen > 4;
287 1         3 my $entries = unpack('V', $$dataPt);
288 1         5 $et->VerboseDir('TNEF Properties', $entries);
289 1         6 my $pos = 4;
290 1         3 my $i;
291 1         4 for ($i=0; $i<$entries; ++$i) {
292 70 50       107 last if $pos + 4 > $dirLen;
293 70         111 my $type = Get16u($dataPt, $pos);
294 70         94 my $tag = Get16u($dataPt, $pos+2);
295 70         81 $pos += 4;
296             # handle named properties (bit 0x8000 set)
297 70 100       95 if ($tag & 0x8000) {
298 9 50       18 last if $pos + 24 > $dirLen;
299 9         31 my $uid = Image::ExifTool::ASF::GetGUID(substr($$dataPt, $pos, 16));
300 9         29 $uid =~ s/-0000-0000-C000-000000000046$//; # remove common suffix
301 9         19 my $idtype = Get32u($dataPt, $pos + 16);
302 9         18 my $num = Get32u($dataPt, $pos + 20);
303 9         13 $pos += 24;
304 9 50       18 if ($idtype == 0) { # number
    0          
305 9         22 $tag = $uid . sprintf('_%.8x', $num);
306             } elsif ($idtype == 1) { # string
307 0 0 0     0 last if $pos + $num > $dirLen or $num < 2;
308             # decode string (ignoring null terminator)
309 0         0 my $name = $et->Decode(substr($$dataPt, $pos, $num-2), 'UTF16');
310 0         0 $tag = "${uid}_$name";
311             AddTagToTable($tagTbl, $tag, {
312             Name => Image::ExifTool::MakeTagName($name)
313 0 0       0 }) unless $$tagTbl{$tag};
314 0         0 $pos += ($num + 3) & 0xfffffffc; # (padded to an even 4 bytes)
315             } else {
316 0         0 last; # error
317             }
318             }
319 70         68 my $count = 1;
320 70         73 my ($multi, $fmt);
321 70 50       94 if ($type & 0x1000) {
322 0         0 $multi = 1;
323 0         0 $type &= 0x0fff;
324 0 0       0 last if $pos + 4 > $dirLen;
325 0         0 $count = Get32u($dataPt, $pos);
326 0         0 $pos += 4;
327             }
328 70 50       138 $fmt = $propType{$type} or last;
329 70         90 while ($count) {
330 70         82 my $size = $fmtSize{$fmt};
331 70         57 my $val;
332 70 50       99 unless ($size) {
333 70 100       160 if ($fmt =~ /(\d+)/) {
    50          
334 36         69 $size = $count * $1 / 8;
335             } elsif ($fmt eq 'null') {
336 0         0 $val = ''; # ($size is already 0)
337             } else {
338             # skip 1 count for "special case" stupidity
339 34 50       55 $pos += 4 unless $multi;
340 34 50       41 last if $pos + 4 > $dirLen;
341 34         65 $size = Get32u($dataPt, $pos);
342 34         36 $pos += 4;
343 34 50       44 last if $pos + $size > $dirLen;
344 34         61 $val = substr($$dataPt, $pos, $size);
345             }
346             }
347 70 100 33     150 if (not defined $val) {
    50          
    100          
    100          
    50          
348 36         58 $val = ReadValue($dataPt, $pos, $fmt, $count, $size);
349 36 100 33     135 if ($type == 0x06 or $type == 0x07 or $type == 0x0b or $type == 0x40) {
      66        
      100        
350 16         29 my @a = split ' ', $val;
351 16 50       38 if ($type == 0x06) { # currency
    50          
    100          
    50          
352 0         0 $_ = $_ / 10000 foreach @a;
353             } elsif ($type == 0x07) { # OLE date
354             # convert time from days since Dec 30, 1899
355 0         0 foreach (@a) {
356 0 0       0 $_ = ($_ - 25569) * 24 * 3600 if $_ != 0;
357 0         0 $_ = Image::ExifTool::ConvertUnixTime($_);
358             }
359             } elsif ($type == 0x0b) { # boolean
360 13 100       55 $_ = $_ ? 'True' : 'False' foreach @a;
361             } elsif ($type == 0x40) { # SYSTIME
362             # convert time from 100-ns intervals since Jan 1, 1601
363 3         16 $_ = Image::ExifTool::ConvertUnixTime($_/1e7-11644473600,1) foreach @a;
364             }
365 16 50       47 $val = @a > 1 ? \@a : $a[0];
366             }
367 36         38 $count = 1; # (read them all already)
368             } elsif ($fmt eq 'GUID') {
369 0         0 $val = Image::ExifTool::ASF::GetGUID($val);
370             } elsif ($fmt eq 'Unicode') {
371 1         3 ($val = $et->Decode($val, 'UTF16')) =~ s/\0+$//;
372             } elsif ($fmt eq 'string') {
373 21         68 $val =~ s/\0+$//;
374             # convert from specified code page if supported
375 21 50       62 $val = $et->Decode($val, $$et{Charset}) if $$et{Charset};
376             } elsif ($fmt eq 'undef' and length $val) {
377 12         15 my $copy = $val;
378 12         30 $val = \$copy; # change to a binary data reference
379             }
380 70         297 $et->HandleTag($tagTbl, $tag, $val,
381             DataPt => $dataPt,
382             DataPos => $dataPos,
383             Start => $pos,
384             Size => $size,
385             Format => sprintf('%s, type 0x%.2x', $fmt, $type),
386             Index => $i,
387             );
388 70         124 $pos += ($size + 3) & 0xfffffffc;
389 70         158 --$count;
390             }
391             }
392 1 50       3 $et->Warn('Error parsing message properties') unless $i == $entries;
393 1         3 return 1;
394             }
395              
396             #------------------------------------------------------------------------------
397             # Extract EXIF information from a TNEF image
398             # Inputs: 0) ExifTool object reference, 1) dirInfo reference
399             # Returns: 1 on success, 0 if this wasn't a valid TNEF file
400             sub ProcessTNEF($$)
401             {
402 1     1 0 2 my ($et, $dirInfo) = @_;
403 1         2 my $raf = $$dirInfo{RAF};
404 1         2 my ($buff, $tagTablePtr);
405              
406             # verify this is a valid TNEF file (read TNEFHeader and TNEFVersion)
407 1 50 33     2 return 0 unless $raf->Read($buff, 0x15) == 0x15 and $raf->Seek(6, 0);
408 1 50       4 return 0 unless $buff =~ /^\x78\x9f\x3e\x22..\x01\x06\x90\x08\0/s;
409 1         5 $et->SetFileType('TNEF');
410 1         5 SetByteOrder('II');
411 1         3 my $tagTbl = GetTagTable('Image::ExifTool::TNEF::Main');
412             # read through the attributes
413 1         5 while ($raf->Read($buff, 9) == 9) {
414             # (ignore the attrLevel byte: 1 for message, 2 for attachment)
415 9         20 my ($tag, $len) = unpack('x1VV', $buff);
416             # increment document number for each attachment
417 9 50       13 $$et{DOC_NUM} = ++$$et{DOC_COUNT} if $tag == 0x069002;
418 9 50       11 $raf->Read($buff, $len) == $len or last;
419 9         13 my $tagInfo = $$tagTbl{$tag};
420 9         10 my ($val, $fmt);
421 9 100 66     25 if ($tagInfo and $$tagInfo{Format}) {
422 5         7 $fmt = $$tagInfo{Format};
423 5 100 66     12 if ($fmt eq 'date' and length($buff) >= 12) {
424 2         6 my @date = unpack('v6', $buff);
425 2         7 $val = sprintf('%.4d:%.2d:%.2d %.2d:%.2d:%.2d', @date);
426             }
427             } else {
428 4         5 $val = $buff;
429             }
430 9         23 $et->HandleTag($tagTbl, $tag, $val,
431             DataPt => \$buff,
432             DataPos => $raf->Tell() - $len,
433             Format => $fmt,
434             );
435 9 50       34 delete $$et{DOC_NUM} if $tag == 0x069005; # end of attachment
436 9         20 $raf->Seek(2, 1); # skip checksum
437             }
438 1         3 delete $$et{DOC_NUM};
439 1         3 return 1;
440             }
441              
442             1; # end
443              
444             __END__