// SPDX-FileCopyrightText: © 2024 Jeffrey C. Ollie // SPDX-License-Identifier: GPL-3.0-or-later //! A single email message in the notmuch database. //! //! Obtained from searches (`Query.searchMessages`), lookups //! (`Database.findMessage`), thread traversal, or indexing. Exposes the //! message's identity, headers, dates, and filenames, and supports mutating //! tags and properties, synchronizing maildir flags, and batching tag changes //! with `freeze`/`thaw`. The message is owned by the query or database it //! came from; `deinit` releases it sooner. const Message = @This(); const std = @import("std"); const c = @import("c"); const Database = @import("Database.zig"); const Error = @import("error.zig").Error; const FilenamesIterator = @import("FilenamesIterator.zig"); const IndexOpts = @import("IndexOpts.zig"); const MaildirFlag = @import("enums.zig").MaildirFlag; const MessageFlag = @import("enums.zig").MessageFlag; const MessagesIterator = @import("MessagesIterator.zig"); const PropertiesIterator = @import("PropertiesIterator.zig"); const status = @import("enums.zig").status; const TagsIterator = @import("TagsIterator.zig"); const wrap = @import("error.zig").wrap; /// For a message returned by `Database.indexFileGetMessage`: whether the /// indexed file carried a message ID already present in the database (its /// index was merged into the existing message). `null` for messages obtained /// any other way. duplicate: ?bool = null, /// The underlying `notmuch_message_t` handle; only needed directly when /// calling libnotmuch C APIs these bindings do not wrap. message: *c.notmuch_message_t, /// Get the database associated with this message. pub fn getDatabase(self: *const Message) Database { return .{ .database = c.notmuch_message_get_database(self.message) orelse unreachable, }; } test getDatabase { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // The message's owning database is the one it was found in. try std.testing.expectEqual(test_db.database.database, message.getDatabase().database); } /// Get the message ID of `Message`. /// /// The returned string belongs to `Message` and as such, should not be modified /// or freed by the caller and will only be valid for as long as the message is /// valid, which is until the query from which it derived is destroyed. /// /// This function will return `null` if it triggers an unhandled Xapian exception. pub fn getMessageID(self: *const Message) ?[:0]const u8 { return std.mem.span(c.notmuch_message_get_message_id(self.message) orelse return null); } test getMessageID { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // The message ID, without the angle brackets of the Message-ID header. try std.testing.expectEqualStrings("one@example.org", message.getMessageID() orelse ""); } /// Get the thread ID of `Message`. /// /// The returned string belongs to `Message` and as such, should not be modified /// or freed by the caller and will only be valid for as long as the message is /// valid, which is until the user calls `deinit` or until the query from which /// it derived is deinitialized. /// /// This function will return `null` if it triggers an unhandled Xapian exception. pub fn getThreadID(self: *const Message) ?[:0]const u8 { return std.mem.span(c.notmuch_message_get_thread_id(self.message) orelse return null); } test getThreadID { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const one = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer one.deinit(); const two = try test_db.database.findMessage("two@example.org") orelse return error.TestUnexpectedResult; defer two.deinit(); // Messages joined by In-Reply-To share a thread ID. try std.testing.expectEqualStrings( one.getThreadID() orelse return error.TestUnexpectedResult, two.getThreadID() orelse return error.TestUnexpectedResult, ); } /// Get a `MessagesIterator` for all of the replies to `Message`. /// /// Note: This call only makes sense if `Message` was ultimately obtained from /// a `Thread` object, (such as by coming directly from the result of calling /// `Thread.getToplevelMessages` or by any number of subsequent calls to /// `getReplies`). /// /// If `Message` was obtained through some non-thread means, (such as by a /// call to `Query.searchMessages`), then this function will return an empty /// iterator. /// /// If there are no replies to `Message`, this function will return an empty /// iterator. /// /// This function also returns an empty iterator if it triggers a Xapian /// exception. /// /// The returned list will be deinitialized when the thread is deinitialized. pub fn getReplies(self: *const Message) MessagesIterator { return .{ .messages = c.notmuch_message_get_replies(self.message), }; } test getReplies { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const db = test_db.database; // Replies are available for messages obtained through a thread. const query = try db.queryCreate("subject:lunch"); defer query.deinit(); var threads = try query.searchThreads(); defer threads.deinit(); const thread = try threads.next() orelse return error.TestUnexpectedResult; defer thread.deinit(); var toplevel = thread.getToplevelMessages(); const top = try toplevel.next() orelse return error.TestUnexpectedResult; var replies = top.getReplies(); const reply = try replies.next() orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("two@example.org", reply.getMessageID() orelse ""); // For a message from a plain search, the iterator is empty. const searched = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer searched.deinit(); var none = searched.getReplies(); try std.testing.expectEqual(@as(?Message, null), try none.next()); } pub const CountFilesError = error{ /// An error occurred while trying to count files. CountFilesError, }; /// Get the total number of files associated with a message. pub fn countFiles(self: *const Message) CountFilesError!usize { std.debug.assert(@typeInfo(c_int).int.bits <= @typeInfo(usize).int.bits); const count = c.notmuch_message_count_files(self.message); if (count < 0) return error.CountFilesError; return @intCast(@max(0, count)); } test countFiles { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // Each corpus message is backed by a single file. try std.testing.expectEqual(@as(usize, 1), try message.countFiles()); } pub const GetFilenameError = error{ /// A Xapian exception occurred. XapianException, }; /// Get a filename for the email corresponding to `Message`. /// /// The returned filename is an absolute filename (the initial component will /// match `Database.getPath`). /// /// The returned string belongs to the message so should not be modified or /// freed by the caller (nor should it be referenced after the message is /// deinitialized). /// /// Note: If this message corresponds to multiple files in the mail store, /// (that is, multiple files contain identical message IDs), this function will /// arbitrarily return a single one of those filenames. See `getFilenames` for /// returning the complete list of filenames. /// /// Returns `error.XapianException` if the call triggers a Xapian exception. pub fn getFilename(self: *const Message) GetFilenameError![:0]const u8 { return std.mem.span(c.notmuch_message_get_filename(self.message) orelse return error.XapianException); } test getFilename { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); const filename = try message.getFilename(); try std.testing.expect(std.fs.path.isAbsolute(filename)); try std.testing.expectEqualStrings(test_db.filename(0), std.fs.path.basename(filename)); } pub const GetFilenamesError = error{ /// A Xapian exception occurred. XapianException, }; /// Get all filenames for the email corresponding to `Message`. /// /// Returns a `FilenamesIterator` listing all the filenames associated with /// `Message`. These files may not have identical content, but each will have /// the identical Message-ID. /// /// Each filename in the iterator is an absolute filename (the initial component /// will match `Database.getPath`). /// /// Returns `error.XapianException` if the call triggers a Xapian exception. pub fn getFilenames(self: *const Message) GetFilenamesError!FilenamesIterator { return .{ .filenames = c.notmuch_message_get_filenames(self.message) orelse return error.XapianException, }; } test getFilenames { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try std.testing.expectEqual(@as(usize, 1), try message.countFiles()); try std.testing.expect(std.mem.endsWith(u8, try message.getFilename(), test_db.filename(0))); var filenames = try message.getFilenames(); defer filenames.deinit(); var count: usize = 0; while (filenames.next()) |_| count += 1; try std.testing.expectEqual(@as(usize, 1), count); } /// Re-index the e-mail corresponding to `Message` using the supplied index /// options (or the database defaults when `indexopts` is `null`). /// /// After reindexing, discard this message object by calling `deinit`: it /// refers to the original message, not to the reindexed message. pub fn reindex(self: *const Message, indexopts: ?IndexOpts) Error!void { return switch (status(c.notmuch_message_reindex(self.message, if (indexopts) |i| i.indexopts else null))) { .success => {}, .duplicate_message_id => {}, .file_error => error.FileError, .file_not_email => error.FileNotEmail, .read_only_database => error.ReadOnlyDatabase, .upgrade_required => error.UpgradeRequired, else => error.Unexpected, }; } test reindex { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const db = test_db.database; const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; try message.addTag("keep"); try message.reindex(null); // The pre-reindex object refers to the original message; discard it and // look the message up again. message.deinit(); const fresh = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer fresh.deinit(); try std.testing.expectEqualStrings("Lunch plans", fresh.getHeader("Subject") orelse ""); var tags = fresh.getTags(); try std.testing.expect(fixture.hasTag(&tags, "keep")); } pub const GetFlagError = error{ /// A Xapian exception occurred. XapianException, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Get a value of a flag for the email corresponding to `Message`. pub fn getFlag(self: *const Message, flag: MessageFlag) GetFlagError!bool { var is_set: c.notmuch_bool_t = undefined; return switch (status(c.notmuch_message_get_flag_st( self.message, @intFromEnum(flag), &is_set, ))) { .success => is_set != 0, .null_pointer => unreachable, .xapian_exception => error.XapianException, else => error.Unexpected, }; } test getFlag { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // An ordinary message is not a ghost. try std.testing.expect(!try message.getFlag(.ghost)); // Flags set with setFlag are read back. message.setFlag(.match, true); try std.testing.expect(try message.getFlag(.match)); } /// Set a value of a flag for the email corresponding to `Message`. pub fn setFlag(self: *const Message, flag: MessageFlag, value: bool) void { c.notmuch_message_set_flag(self.message, @intFromEnum(flag), @intFromBool(value)); } /// Get the date of `Message` as the number of seconds since the Unix epoch /// (1970-01-01 00:00:00 UTC). /// /// For the original textual representation of the `Date` header from the /// message call `getHeader` with a header value of "date". /// /// Returns `null` in case of error. pub fn getDate(self: *const Message) ?i64 { std.debug.assert(@typeInfo(c.time_t).int.bits <= @typeInfo(i64).int.bits); const time = c.notmuch_message_get_date(self.message); if (time == 0) return null; return @intCast(time); } test getDate { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // Mon, 01 Apr 2024 09:00:00 +0000, as seconds since the Unix epoch. try std.testing.expectEqual(@as(?i64, 1711962000), message.getDate()); } /// Get the value of the specified header from `Message` as a UTF-8 string. /// /// Common headers are stored in the database when the message is indexed and /// will be returned from the database. Other headers will be read from the /// actual message file. /// /// The header name is case insensitive. /// /// The returned string belongs to the message so should not be modified or /// freed by the caller (nor should it be referenced after the message is /// deinitialized). /// /// Returns an empty string ("") if the message does not contain a header line /// matching 'header'. Returns `null` if any error occurs. pub fn getHeader(self: *const Message, header: [:0]const u8) ?[:0]const u8 { return std.mem.span(c.notmuch_message_get_header(self.message, header) orelse return null); } test getHeader { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try std.testing.expectEqualStrings("one@example.org", message.getMessageID() orelse ""); try std.testing.expect(message.getThreadID() != null); // Mon, 01 Apr 2024 09:00:00 +0000 try std.testing.expectEqual(@as(?i64, 1711962000), message.getDate()); try std.testing.expectEqualStrings("Alice ", message.getHeader("From") orelse ""); try std.testing.expectEqualStrings("", message.getHeader("X-Nonexistent") orelse "missing"); } /// Get the tags for `Message`, returning a `TagsIterator` object which can be /// used to iterate over all tags. /// /// The tags object is owned by the message and as such, will only be valid /// for as long as the message is valid, which is until the query from which it /// derived is deinitialized. pub fn getTags(self: *const Message) TagsIterator { return .{ .tags = c.notmuch_message_get_tags(self.message), }; } test getTags { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // A freshly indexed message carries no tags. var tags = message.getTags(); try std.testing.expectEqual(@as(usize, 0), fixture.countTags(&tags)); try message.addTag("inbox"); tags = message.getTags(); try std.testing.expect(fixture.hasTag(&tags, "inbox")); } pub const AddTagError = error{ /// The length of `tag` is too long (exceeds NOTMUCH_TAG_MAX). TagTooLong, /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Add a tag to the given message. pub fn addTag(self: *const Message, tag: [:0]const u8) AddTagError!void { return switch (status(c.notmuch_message_add_tag(self.message, tag))) { .success => {}, .null_pointer => unreachable, .tag_too_long => error.TagTooLong, .read_only_database => error.ReadOnlyDatabase, else => error.Unexpected, }; } test addTag { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addTag("foo"); try message.addTag("bar"); var tags = message.getTags(); try std.testing.expectEqual(@as(usize, 2), fixture.countTags(&tags)); try message.removeTag("foo"); tags = message.getTags(); try std.testing.expect(!fixture.hasTag(&tags, "foo")); tags = message.getTags(); try std.testing.expect(fixture.hasTag(&tags, "bar")); try message.removeAllTags(); tags = message.getTags(); try std.testing.expectEqual(@as(usize, 0), fixture.countTags(&tags)); } pub const RemoveTagError = error{ /// The length of `tag` is too long (exceeds NOTMUCH_TAG_MAX). TagTooLong, /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Remove a tag from the given message. pub fn removeTag(self: *const Message, tag: [:0]const u8) RemoveTagError!void { return switch (status(c.notmuch_message_remove_tag(self.message, tag))) { .success => {}, .null_pointer => unreachable, .tag_too_long => error.TagTooLong, .read_only_database => error.ReadOnlyDatabase, else => error.Unexpected, }; } test removeTag { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addTag("spam"); try message.removeTag("spam"); var tags = message.getTags(); try std.testing.expect(!fixture.hasTag(&tags, "spam")); // Removing a tag the message does not carry is not an error. try message.removeTag("spam"); } pub const RemoveAllTagsError = error{ /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// A Xapian exception occurred. XapianException, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Remove all tags from the given message. /// /// See `freeze` for an example showing how to safely replace tag values. pub fn removeAllTags(self: *const Message) RemoveAllTagsError!void { return switch (status(c.notmuch_message_remove_all_tags(self.message))) { .success => {}, .read_only_database => error.ReadOnlyDatabase, .xapian_exception => error.XapianException, else => error.Unexpected, }; } test removeAllTags { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addTag("alpha"); try message.addTag("beta"); try message.removeAllTags(); var tags = message.getTags(); try std.testing.expectEqual(@as(usize, 0), fixture.countTags(&tags)); } /// Add/remove tags according to maildir flags in the message filename(s). /// /// This function examines the filenames of `Message` for maildir flags, and /// adds or removes tags on `Message` as follows when these flags are present: /// /// Flag Action if present /// ---- ----------------- /// 'D' Adds the "draft" tag to the message /// 'F' Adds the "flagged" tag to the message /// 'P' Adds the "passed" tag to the message /// 'R' Adds the "replied" tag to the message /// 'S' Removes the "unread" tag from the message /// /// For each flag that is not present, the opposite action (add/remove) is /// performed for the corresponding tags. /// /// Flags are identified as trailing components of the filename after a /// sequence of ":2,". /// /// If there are multiple filenames associated with this message, the flag /// is considered present if it appears in one or more filenames. (That is, /// the flags from the multiple filenames are combined with the logical OR /// operator.) /// /// A client can ensure that notmuch database tags remain synchronized /// with maildir flags by calling this function after each call to /// `Database.indexFile`. See also `tagsToMaildirFlags` for synchronizing tag /// changes back to maildir flags. pub fn maildirFlagsToTags(self: *const Message) Error!void { try wrap(c.notmuch_message_maildir_flags_to_tags(self.message)); } test maildirFlagsToTags { const fixture = @import("testing.zig"); const alloc = std.testing.allocator; var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const db = test_db.database; // A message delivered straight into cur/ with the "flagged" flag set but // not "seen" — what a client saving an already-marked message does. const delivered = try test_db.deliver( \\From: Erin \\To: Alice \\Subject: Flagged mail \\Date: Thu, 04 Apr 2024 10:00:00 +0000 \\Message-ID: \\ \\This one is flagged. \\ , .{ .to = .{ .cur = .{ .flagged = true } } }); const path = try test_db.pathOf(&delivered); defer alloc.free(path); const message = try db.indexFileGetMessage(path, null); defer message.deinit(); try std.testing.expect(try message.hasMaildirFlag(.F)); try std.testing.expect(!try message.hasMaildirFlag(.S)); try std.testing.expect(!try message.hasMaildirFlag(.D)); try message.maildirFlagsToTags(); // F present adds "flagged"; S absent performs the opposite of removing // "unread", i.e. adds it. var tags = message.getTags(); try std.testing.expect(fixture.hasTag(&tags, "flagged")); tags = message.getTags(); try std.testing.expect(fixture.hasTag(&tags, "unread")); } pub const HasMaildirFlagError = error{ /// A Xapian exception occurred. XapianException, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Check message for maildir flag. pub fn hasMaildirFlag(self: *const Message, flag: MaildirFlag) HasMaildirFlagError!bool { var is_set: c.notmuch_bool_t = undefined; return switch (status(c.notmuch_message_has_maildir_flag_st(self.message, @intFromEnum(flag), &is_set))) { .success => is_set != 0, .null_pointer => unreachable, .xapian_exception => error.XapianException, else => error.Unexpected, }; } test hasMaildirFlag { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // The corpus is delivered into new/, where a name has no ":2," maildir // info part to carry a flag — which is what makes it new. try std.testing.expect(!try message.hasMaildirFlag(.S)); try std.testing.expect(!try message.hasMaildirFlag(.F)); } /// Rename message filename(s) to encode tags as maildir flags. /// /// Specifically, for each filename corresponding to this message: /// /// If the filename is not in a maildir directory, do nothing. (A maildir /// directory is determined as a directory named "new" or "cur".) Similarly, if /// the filename has invalid maildir info, (repeated or outof-ASCII-order flag /// characters after ":2,"), then do nothing. /// /// If the filename is in a maildir directory, rename the file so that its /// filename ends with the sequence ":2," followed by zero or more of the /// following single-character flags (in ASCII order): /// /// * flag 'D' iff the message has the "draft" tag /// * flag 'F' iff the message has the "flagged" tag /// * flag 'P' iff the message has the "passed" tag /// * flag 'R' iff the message has the "replied" tag /// * flag 'S' iff the message does not have the "unread" tag /// /// Any existing flags unmentioned in the list above will be preserved in the /// renaming. /// /// Also, if this filename is in a directory named "new", rename it to be within /// the neighboring directory named "cur". /// /// A client can ensure that maildir filename flags remain synchronized /// with notmuch database tags by calling this function after changing tags /// (after calls to `addTag`, `removeTag`, or `freeze`/`thaw`). See also /// `maildirFlagsToTags` for synchronizing maildir flag changes back to tags. pub fn tagsToMaildirFlags(self: *const Message) Error!void { try wrap(c.notmuch_message_tags_to_maildir_flags(self.message)); } test tagsToMaildirFlags { const fixture = @import("testing.zig"); const alloc = std.testing.allocator; const io = std.testing.io; var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const db = test_db.database; const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addTag("flagged"); try message.addTag("passed"); // No "unread" tag, so the seen flag is set as well. try message.tagsToMaildirFlags(); // The corpus message was delivered into new/, which has nowhere to write // a flag, so notmuch moved it into cur/ and appended the info part to the // name it arrived under. const flagged_name = try std.fmt.allocPrint(alloc, "{s}:2,FPS", .{test_db.filename(0)}); defer alloc.free(flagged_name); const filename = try message.getFilename(); try std.testing.expect(std.mem.endsWith(u8, filename, flagged_name)); try std.testing.expect(try message.hasMaildirFlag(.F)); try std.testing.expect(try message.hasMaildirFlag(.P)); try std.testing.expect(try message.hasMaildirFlag(.S)); // The file was renamed on disk, not copied. const in_cur = try std.fs.path.join(alloc, &.{ "mail", "cur", flagged_name }); defer alloc.free(in_cur); try test_db.tmp.dir.access(io, in_cur, .{}); const in_new = try std.fs.path.join(alloc, &.{ "mail", "new", test_db.filename(0) }); defer alloc.free(in_new); try std.testing.expectError(error.FileNotFound, test_db.tmp.dir.access(io, in_new, .{})); // The message kept the identity it was delivered under, so a maildir // reader still finds it — and reads back the flags notmuch wrote. var moved = try test_db.mailbox.find(io, test_db.delivered[0].id()) orelse return error.TestUnexpectedResult; try std.testing.expect(moved.subdir == .cur); const flags = moved.flags(); try std.testing.expect(flags.flagged and flags.passed and flags.seen); } /// Freeze the current state of `Message` within the database. /// /// This means that changes to the message state, (via `addTag`, `removeTag`, /// and `removeAllTags`), will not be committed to the database until the /// message is thawed with `thaw`. /// /// Multiple calls to `freeze`/`thaw` are valid and these calls will "stack". /// That is there must be as many calls to thaw as to freeze before a message is /// actually thawed. /// /// The ability to do `freeze`/`thaw` allows for safe transactions to change /// tag values. For example, explicitly setting a message to have a given set of /// tags might look like this: /// /// try message.freeze(); /// defer message.thaw() catch {}; /// /// try message.removeAllTags(); /// /// for (tags) |tag| /// try message.addTag(tag); /// /// With `freeze`/`thaw` used like this, the message in the database is /// guaranteed to have either the full set of original tag values, or the full /// set of new tag values, but nothing in between. /// /// Imagine the example above without `freeze`/`thaw` and the operation somehow /// getting interrupted. This could result in the message being left with no /// tags if the interruption happened after `removeAllTags` but before `addTag`. pub fn freeze(self: *const Message) Error!void { try wrap(c.notmuch_message_freeze(self.message)); } test freeze { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("two@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.freeze(); try message.addTag("frozen"); try message.thaw(); var tags = message.getTags(); try std.testing.expect(fixture.hasTag(&tags, "frozen")); } /// Thaw the current `Message`, synchronizing any changes that may have /// occurred while `Message` was frozen into the `notmuch` database. /// /// See `freeze` for an example of how to use this function to /// safely provide tag changes. /// /// Multiple calls to `freeze`/`thaw` are valid and these calls with "stack". /// That is there must be as many calls to `thaw` as to `freeze` before a /// message is actually thawed. pub fn thaw(self: *const Message) Error!void { try wrap(c.notmuch_message_thaw(self.message)); } /// Deinitialize a `Message` object. /// /// It can be useful to call this function in the case of a single query /// object with many messages in the result, such as iterating over the entire /// database. Otherwise, it's fine to never call this function and there will /// still be no memory leaks. (The memory from the messages get reclaimed when /// the containing query is deinitialized.) pub fn deinit(self: *const Message) void { c.notmuch_message_destroy(self.message); } /// Retrieve the value for a single property key. /// /// Returns a string owned by the `Message` or `null` if there is no such /// key. In the case of multiple values for the given key, the first one is /// retrieved. pub fn getProperty(self: *const Message, key: [:0]const u8) ?[:0]const u8 { var value: [*c]const u8 = null; return switch (status(c.notmuch_message_get_property(self.message, key, &value))) { .success => std.mem.span(value orelse return null), .null_pointer => unreachable, else => null, }; } test getProperty { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); // An absent property reads as null. try std.testing.expectEqual(@as(?[:0]const u8, null), message.getProperty("zig.absent")); try message.addProperty("zig.key", "value"); try std.testing.expectEqualStrings( "value", message.getProperty("zig.key") orelse return error.TestUnexpectedResult, ); } pub const AddPropertyError = error{ /// `key` may not contain an '=' character. IllegalArgument, /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Add a (key,value) pair to a message. pub fn addProperty(self: *const Message, key: [:0]const u8, value: [:0]const u8) AddPropertyError!void { return switch (status(c.notmuch_message_add_property(self.message, key, value))) { .success => {}, .null_pointer => unreachable, .illegal_argument => return error.IllegalArgument, .read_only_database => return error.ReadOnlyDatabase, else => error.Unexpected, }; } test addProperty { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addProperty("test.key", "value1"); try message.addProperty("test.key", "value2"); try std.testing.expectEqualStrings( "value1", message.getProperty("test.key") orelse return error.TestUnexpectedResult, ); try std.testing.expectEqual(@as(usize, 2), try message.countProperties("test.key")); var properties = message.getProperties("test.key", true); defer properties.deinit(); var count: usize = 0; while (properties.next()) |kv| { try std.testing.expectEqualStrings("test.key", kv.key); count += 1; } try std.testing.expectEqual(@as(usize, 2), count); try message.removeProperty("test.key", "value1"); try std.testing.expectEqual(@as(usize, 1), try message.countProperties("test.key")); try message.removeAllProperties(null); try std.testing.expectEqual(@as(usize, 0), try message.countProperties("test.key")); } pub const RemovePropertyError = error{ /// `key` may not contain an '=' character. IllegalArgument, /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Remove a (key,value) pair from a message. /// /// It is not an error to remove a non-existent (key,value) pair pub fn removeProperty(self: *const Message, key: [:0]const u8, value: [:0]const u8) RemovePropertyError!void { return switch (status(c.notmuch_message_remove_property(self.message, key, value))) { .success => {}, .null_pointer => unreachable, .illegal_argument => error.IllegalArgument, .read_only_database => error.ReadOnlyDatabase, else => error.Unexpected, }; } test removeProperty { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addProperty("zig.key", "first"); try message.addProperty("zig.key", "second"); try message.removeProperty("zig.key", "first"); try std.testing.expectEqual(@as(usize, 1), try message.countProperties("zig.key")); // Removing a non-existent pair is not an error. try message.removeProperty("zig.key", "first"); } pub const RemoveAllPropertiesError = error{ /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Remove all (key,value) pairs from the given message. pub fn removeAllProperties( /// The message to operate on. self: *const Message, /// key to delete properties for. If `null`, delete properties for all keys key: ?[:0]const u8, ) RemoveAllPropertiesError!void { return switch (status(c.notmuch_message_remove_all_properties(self.message, key orelse null))) { .success => {}, .read_only_database => error.ReadOnlyDatabase, else => error.Unexpected, }; } test removeAllProperties { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addProperty("zig.a", "1"); try message.addProperty("zig.b", "2"); // With a key, only that key's properties are removed. try message.removeAllProperties("zig.a"); try std.testing.expectEqual(@as(usize, 0), try message.countProperties("zig.a")); try std.testing.expectEqual(@as(usize, 1), try message.countProperties("zig.b")); // With null, all properties are removed. try message.removeAllProperties(null); try std.testing.expectEqual(@as(usize, 0), try message.countProperties("zig.b")); } pub const RemoveAllPropertiesWithPrefixError = error{ /// Database was opened in read-only mode so message cannot be modified. ReadOnlyDatabase, /// libnotmuch returned an undocumented status code. Unexpected, }; /// Remove all (prefix*,value) pairs from the given message pub fn removeAllPropertiesWithPrefix( /// The message to operate on. self: *const Message, /// Delete properties with keys that start with prefix. If `null`, delete all properties. prefix: ?[:0]const u8, ) RemoveAllPropertiesWithPrefixError!void { return switch (status(c.notmuch_message_remove_all_properties_with_prefix(self.message, prefix orelse null))) { .success => {}, .read_only_database => error.ReadOnlyDatabase, else => error.Unexpected, }; } test removeAllPropertiesWithPrefix { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addProperty("zig.a", "1"); try message.addProperty("zig.b", "2"); try message.addProperty("other.c", "3"); try message.removeAllPropertiesWithPrefix("zig."); try std.testing.expectEqual(@as(usize, 0), try message.countProperties("zig.a")); try std.testing.expectEqual(@as(usize, 0), try message.countProperties("zig.b")); try std.testing.expectEqual(@as(usize, 1), try message.countProperties("other.c")); } /// Get the properties for `Message`, returning a `PropertiesIterator` object /// which can be used to iterate over all properties. /// /// The `PropertiesIterator` object is owned by the message and as such, will only /// be valid for as long as the message is valid, which is until the query from /// which it derived is deinitialized. pub fn getProperties( /// The message to examine. self: *const Message, /// Key or key prefix. key: [:0]const u8, /// If `true`, require exact match with key, otherwise treat as prefix. exact: bool, ) PropertiesIterator { return .{ .properties = c.notmuch_message_get_properties(self.message, key, @intFromBool(exact)), }; } test getProperties { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try message.addProperty("zig.a", "1"); try message.addProperty("zig.ab", "2"); // With exact = true, only the exact key matches. var exact = message.getProperties("zig.a", true); defer exact.deinit(); var exact_count: usize = 0; while (exact.next()) |kv| : (exact_count += 1) try std.testing.expectEqualStrings("zig.a", kv.key); try std.testing.expectEqual(@as(usize, 1), exact_count); // With exact = false, the key is treated as a prefix. var prefixed = message.getProperties("zig.a", false); defer prefixed.deinit(); var prefix_count: usize = 0; while (prefixed.next()) |_| prefix_count += 1; try std.testing.expectEqual(@as(usize, 2), prefix_count); } /// Return the number of properties named "key" belonging to the specific message. pub fn countProperties(self: *const Message, key: [:0]const u8) Error!usize { std.debug.assert(@typeInfo(c_uint).int.bits <= @typeInfo(usize).int.bits); var count: c_uint = undefined; try wrap(c.notmuch_message_count_properties(self.message, key, &count)); return @intCast(count); } test countProperties { const fixture = @import("testing.zig"); var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; defer message.deinit(); try std.testing.expectEqual(@as(usize, 0), try message.countProperties("zig.key")); try message.addProperty("zig.key", "first"); try message.addProperty("zig.key", "second"); try std.testing.expectEqual(@as(usize, 2), try message.countProperties("zig.key")); } test { _ = std.testing.refAllDecls(@This()); }