2020
2121import java .io .BufferedReader ;
2222import java .io .IOException ;
23+ import java .io .InputStream ;
2324import java .io .InputStreamReader ;
2425import java .io .UnsupportedEncodingException ;
26+ import java .nio .ByteBuffer ;
27+ import java .nio .ByteOrder ;
2528import java .nio .charset .Charset ;
2629import java .nio .charset .IllegalCharsetNameException ;
2730import java .nio .charset .UnsupportedCharsetException ;
5659import org .apache .poi .hsmf .datatypes .StringChunk ;
5760import org .apache .poi .hsmf .datatypes .Types ;
5861import org .apache .poi .hsmf .exceptions .ChunkNotFoundException ;
62+ import org .apache .poi .poifs .filesystem .DirectoryEntry ;
5963import org .apache .poi .poifs .filesystem .DirectoryNode ;
64+ import org .apache .poi .poifs .filesystem .DocumentEntry ;
65+ import org .apache .poi .poifs .filesystem .DocumentInputStream ;
6066import org .apache .poi .util .CodePageUtil ;
6167import org .slf4j .Logger ;
6268import org .slf4j .LoggerFactory ;
7985import org .apache .tika .parser .html .JSoupParser ;
8086import org .apache .tika .parser .mailcommons .MailDateParser ;
8187import org .apache .tika .parser .microsoft .msg .ExtendedMetadataExtractor ;
88+ import org .apache .tika .parser .microsoft .msg .RTFEncapsulatedHTMLExtractor ;
8289import org .apache .tika .parser .microsoft .rtf .RTFParser ;
8390import org .apache .tika .parser .txt .CharsetDetector ;
8491import org .apache .tika .parser .txt .CharsetMatch ;
@@ -173,6 +180,7 @@ private static void loadMessageClasses() {
173180 private static Pattern HEADER_KEY_PAT =
174181 Pattern .compile ("\\ A([\\ x21-\\ x39\\ x3B-\\ x7E]+):(.*?)\\ Z" );
175182
183+ private final DirectoryNode root ;
176184 private final MAPIMessage msg ;
177185 private final ParseContext parseContext ;
178186 private final boolean extractAllAlternatives ;
@@ -181,6 +189,7 @@ private static void loadMessageClasses() {
181189
182190 public OutlookExtractor (DirectoryNode root , Metadata metadata , ParseContext context ) throws TikaException {
183191 super (context , metadata );
192+ this .root = root ;
184193 this .parseContext = context ;
185194 this .extractAllAlternatives =
186195 context .get (OfficeParserConfig .class ).isExtractAllAlternativesFromMSG ();
@@ -317,18 +326,7 @@ private void _parse(XHTMLContentHandler xhtml) throws TikaException, SAXExceptio
317326
318327 private void updateAttachmentMetadata (AttachmentChunks attachment , Metadata metadata ,
319328 Set <String > contentIdNames ) {
320- StringChunk contentIdChunk = attachment .getAttachContentId ();
321- if (contentIdChunk != null ) {
322- String contentId = contentIdChunk .getValue ();
323- if (! StringUtils .isBlank (contentId )) {
324- contentId = contentId .trim ();
325- if (contentIdNames .contains (contentId )) {
326- metadata .set (TikaCoreProperties .EMBEDDED_RESOURCE_TYPE_KEY ,
327- TikaCoreProperties .EmbeddedResourceType .INLINE .name ());
328- }
329- metadata .set (MAPI .ATTACH_CONTENT_ID , contentId );
330- }
331- }
329+ // Extract string-based metadata from POI's named chunk getters
332330 addStringChunkToMetadata (MAPI .ATTACH_LONG_PATH_NAME , attachment .getAttachLongPathName (), metadata );
333331 addStringChunkToMetadata (MAPI .ATTACH_LONG_FILE_NAME , attachment .getAttachLongFileName (), metadata );
334332 addStringChunkToMetadata (MAPI .ATTACH_FILE_NAME , attachment .getAttachFileName (), metadata );
@@ -337,6 +335,129 @@ private void updateAttachmentMetadata(AttachmentChunks attachment, Metadata meta
337335 addStringChunkToMetadata (MAPI .ATTACH_EXTENSION , attachment .getAttachExtension (), metadata );
338336 addStringChunkToMetadata (MAPI .ATTACH_MIME , attachment .getAttachMimeTag (), metadata );
339337 addStringChunkToMetadata (MAPI .ATTACH_LANGUAGE , attachment .getAttachLanguage (), metadata );
338+
339+ // Extract fixed properties from the attachment's __properties_version1.0 stream
340+ // POI's AttachmentChunks doesn't parse this stream, so we read it directly.
341+ Map <Integer , Long > attachProps = readAttachmentProperties (attachment .getPOIFSName ());
342+ Long attachFlags = attachProps .get (PID_TAG_ATTACH_FLAGS );
343+ if (attachFlags != null ) {
344+ metadata .set (MAPI .ATTACH_FLAGS , attachFlags .intValue ());
345+ }
346+ Long attachHidden = attachProps .get (PID_TAG_ATTACHMENT_HIDDEN );
347+ if (attachHidden != null ) {
348+ metadata .set (MAPI .ATTACH_HIDDEN , attachHidden .intValue () != 0 );
349+ }
350+
351+ // Determine inline vs attachment
352+ String contentId = null ;
353+ StringChunk contentIdChunk = attachment .getAttachContentId ();
354+ if (contentIdChunk != null ) {
355+ String rawCid = contentIdChunk .getValue ();
356+ if (!StringUtils .isBlank (rawCid )) {
357+ contentId = rawCid .trim ();
358+ metadata .set (MAPI .ATTACH_CONTENT_ID , contentId );
359+ }
360+ }
361+
362+ if (contentId != null && contentIdNames .contains (contentId )) {
363+ // Layer 1: CID referenced in the message body — high confidence inline
364+ metadata .set (TikaCoreProperties .EMBEDDED_RESOURCE_TYPE_KEY ,
365+ TikaCoreProperties .EmbeddedResourceType .INLINE .name ());
366+ } else if (contentId != null
367+ && attachFlags != null
368+ && (attachFlags & ATT_RENDERED_IN_BODY ) != 0
369+ && isInlineableMimeType (metadata .get (MAPI .ATTACH_MIME ))) {
370+ // Layer 2: MAPI says rendered in body + image MIME type — the CID regex
371+ // missed it (e.g. encapsulated RTF with stripped img tags)
372+ metadata .set (TikaCoreProperties .EMBEDDED_RESOURCE_TYPE_KEY ,
373+ TikaCoreProperties .EmbeddedResourceType .INLINE .name ());
374+ }
375+ }
376+
377+ private static final Set <String > INLINEABLE_MIME_TYPES = Set .of (
378+ "application/x-ms-wmz" ,
379+ "application/x-ms-emz" ,
380+ "application/x-msmetafile" ,
381+ "image/x-wmf" ,
382+ "image/x-emf" ,
383+ "image/wmf" ,
384+ "image/emf"
385+ );
386+
387+ /**
388+ * Returns true for MIME types that are safe to label as INLINE.
389+ * We gate on this to avoid marking PDFs, DOCX, etc. as inline — downstream
390+ * consumers use INLINE to decide what to index separately.
391+ */
392+ private static boolean isInlineableMimeType (String mimeType ) {
393+ if (StringUtils .isBlank (mimeType )) {
394+ return false ;
395+ }
396+ String lower = mimeType .toLowerCase (Locale .ROOT ).trim ();
397+ return lower .startsWith ("image/" ) || INLINEABLE_MIME_TYPES .contains (lower );
398+ }
399+
400+ // PidTagAttachFlags (0x3714) — bit flags indicating which body formats reference this
401+ private static final int PID_TAG_ATTACH_FLAGS = 0x3714 ;
402+ // Bit 2 = ATT_RENDERED_IN_BODY: this attachment is referenced by the body
403+ private static final int ATT_RENDERED_IN_BODY = 0x4 ;
404+ // PidTagAttachmentHidden (0x7FFE) — boolean, true if hidden from end user (inline images)
405+ private static final int PID_TAG_ATTACHMENT_HIDDEN = 0x7FFE ;
406+
407+ /**
408+ * Read fixed MAPI properties from the __properties_version1.0 stream inside an
409+ * attachment storage. POI's {@link AttachmentChunks} does not parse this stream.
410+ *
411+ * <p>The stream format is: 8-byte header, followed by 16-byte property entries.
412+ * Each entry: 2 bytes property type, 2 bytes property ID, 4 bytes flags,
413+ * 8 bytes value (inline for fixed-size types).</p>
414+ *
415+ * @param poifsName the OLE2 directory name for this attachment
416+ * (e.g. "__attach_version1.0_#00000000")
417+ * @return map of property ID to value for fixed-size integer/boolean properties
418+ */
419+ private Map <Integer , Long > readAttachmentProperties (String poifsName ) {
420+ Map <Integer , Long > result = new HashMap <>();
421+ try {
422+ DirectoryEntry attachDir = (DirectoryEntry ) root .getEntry (poifsName );
423+ DocumentEntry propsEntry =
424+ (DocumentEntry ) attachDir .getEntry ("__properties_version1.0" );
425+ byte [] data ;
426+ try (InputStream dis = new DocumentInputStream (propsEntry )) {
427+ data = dis .readAllBytes ();
428+ }
429+ if (data .length < 8 ) {
430+ return result ;
431+ }
432+ ByteBuffer buf = ByteBuffer .wrap (data ).order (ByteOrder .LITTLE_ENDIAN );
433+ int offset = 8 ; // skip 8-byte header
434+ while (offset + 16 <= data .length ) {
435+ int propType = buf .getShort (offset ) & 0xFFFF ;
436+ int propId = buf .getShort (offset + 2 ) & 0xFFFF ;
437+ long value ;
438+ switch (propType ) {
439+ case 0x0003 : // PtypInteger32
440+ value = buf .getInt (offset + 8 ) & 0xFFFFFFFFL ;
441+ result .put (propId , value );
442+ break ;
443+ case 0x000B : // PtypBoolean
444+ value = buf .getShort (offset + 8 ) & 0xFFFF ;
445+ result .put (propId , value );
446+ break ;
447+ case 0x0014 : // PtypInteger64
448+ value = buf .getLong (offset + 8 );
449+ result .put (propId , value );
450+ break ;
451+ default :
452+ // skip variable-length, binary, time and other types
453+ break ;
454+ }
455+ offset += 16 ;
456+ }
457+ } catch (Exception e ) {
458+ LOGGER .debug ("Could not read attachment properties for {}" , poifsName , e );
459+ }
460+ return result ;
340461 }
341462
342463 private void addStringChunkToMetadata (Property property , StringChunk stringChunk , Metadata metadata ) {
@@ -534,8 +655,13 @@ private void _handleBestBodyChunk(Chunk htmlChunk, Chunk rtfChunk, Chunk textChu
534655 }
535656
536657 private void extractContentIdNamesFromRtf (byte [] data , Metadata metadata , Set <String > contentIdNames ) {
537- //for now, hope that there's encapsulated html
538- //TODO: check for encapsulated html. If it doesn't exist, handle RTF specifically
658+ // Try to de-encapsulate the HTML from the RTF first
659+ String html = RTFEncapsulatedHTMLExtractor .extract (data );
660+ if (html != null ) {
661+ extractContentIdNamesFromHtml (html .getBytes (UTF_8 ), metadata , contentIdNames );
662+ return ;
663+ }
664+ // Fall back to scanning the raw RTF bytes for cid: references
539665 extractContentIdNamesFromHtml (data , metadata , contentIdNames );
540666 }
541667
0 commit comments