Merge remote-tracking branch 'origin/main' into feat/app-rail-layout
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m44s
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m44s
ci / announce (pull_request) Skipped
This commit is contained in:
commit
6190d2b4f5
23 files changed
+1011
-44
No files matched your search
@@ -651,6 +651,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} weitere",
|
||||
|
||||
"Contacts": "Kontakte",
|
||||
|
||||
"Collected": "Gesammelt",
|
||||
|
||||
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
|
||||
"Contacts are not available": "Kontakte sind nicht verfügbar",
|
||||
"New contact": "Neuer Kontakt",
|
||||
"Edit contact": "Kontakt bearbeiten",
|
||||
@@ -1357,6 +1363,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Trotzdem senden",
|
||||
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
|
||||
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Es konnte nicht geprüft werden, ob diese Nachricht schon gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Es konnte nicht bestätigt werden, ob diese Nachricht gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Diese Nachricht war bereits gesendet und wurde nicht noch einmal gesendet.",
|
||||
"Send invites": "Einladungen senden",
|
||||
"Send scheduled for {when}": "Senden geplant für {when}",
|
||||
"Send without a subject?": "Ohne Betreff senden?",
|
||||
|
||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} más",
|
||||
|
||||
"Contacts": "Contactos",
|
||||
|
||||
"Collected": "Recopilados",
|
||||
|
||||
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
|
||||
"Contacts are not available": "Los contactos no están disponibles",
|
||||
"New contact": "Contacto nuevo",
|
||||
"Edit contact": "Editar el contacto",
|
||||
@@ -1330,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar de todos modos",
|
||||
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
|
||||
"Send failed: {error}": "No se pudo enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "No se pudo comprobar si este mensaje ya se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "No se pudo confirmar si este mensaje se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Este mensaje ya se había enviado, así que no se ha vuelto a enviar.",
|
||||
"Send invites": "Enviar invitaciones",
|
||||
"Send scheduled for {when}": "Envío programado para {when}",
|
||||
"Send without a subject?": "¿Enviar sin asunto?",
|
||||
|
||||
@@ -648,6 +648,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} autres",
|
||||
|
||||
"Contacts": "Contacts",
|
||||
|
||||
"Collected": "Collectés",
|
||||
|
||||
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
|
||||
"Contacts are not available": "Les contacts ne sont pas disponibles",
|
||||
"New contact": "Nouveau contact",
|
||||
"Edit contact": "Modifier le contact",
|
||||
@@ -1335,6 +1341,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Envoyer quand même",
|
||||
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
|
||||
"Send failed: {error}": "Échec de l’envoi : {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Impossible de vérifier si ce message a déjà été envoyé. Consultez Envoyés avant de le renvoyer.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Impossible de confirmer l’envoi de ce message. Consultez Envoyés avant de le renvoyer.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Ce message avait déjà été envoyé, il n’a donc pas été renvoyé.",
|
||||
"Send invites": "Envoyer les invitations",
|
||||
"Send scheduled for {when}": "Envoi programmé pour {when}",
|
||||
"Send without a subject?": "Envoyer sans objet ?",
|
||||
|
||||
@@ -642,6 +642,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "他 {n} 件",
|
||||
|
||||
"Contacts": "連絡先",
|
||||
|
||||
"Collected": "収集したアドレス",
|
||||
|
||||
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
|
||||
"Contacts are not available": "連絡先は利用できません",
|
||||
"New contact": "新しい連絡先",
|
||||
"Edit contact": "連絡先を編集",
|
||||
@@ -1338,6 +1344,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "このまま送信",
|
||||
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
|
||||
"Send failed: {error}": "送信できませんでした: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "このメッセージが送信済みかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "このメッセージが送信されたかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"This message had already been sent, so it wasn't sent again.": "このメッセージはすでに送信されていたため、再送信しませんでした。",
|
||||
"Send invites": "招待を送信",
|
||||
"Send scheduled for {when}": "{when} に送信を予約しました",
|
||||
"Send without a subject?": "件名なしで送信しますか?",
|
||||
|
||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} meer",
|
||||
|
||||
"Contacts": "Contacten",
|
||||
|
||||
"Collected": "Verzameld",
|
||||
|
||||
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
|
||||
"Contacts are not available": "Contacten zijn niet beschikbaar",
|
||||
"New contact": "Nieuw contact",
|
||||
"Edit contact": "Contact bewerken",
|
||||
@@ -1330,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Toch verzenden",
|
||||
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
|
||||
"Send failed: {error}": "Verzenden mislukt: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Kon niet controleren of dit bericht al is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Kon niet bevestigen of dit bericht is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Dit bericht was al verzonden en is daarom niet opnieuw verstuurd.",
|
||||
"Send invites": "Uitnodigingen verzenden",
|
||||
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
||||
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
||||
|
||||
@@ -646,6 +646,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} outros",
|
||||
|
||||
"Contacts": "Contatos",
|
||||
|
||||
"Collected": "Coletados",
|
||||
|
||||
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
|
||||
"Contacts are not available": "Os contatos não estão disponíveis",
|
||||
"New contact": "Novo contato",
|
||||
"Edit contact": "Editar o contato",
|
||||
@@ -1333,6 +1339,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar mesmo assim",
|
||||
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
|
||||
"Send failed: {error}": "Falha ao enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Não foi possível verificar se esta mensagem já foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Não foi possível confirmar se esta mensagem foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Esta mensagem já tinha sido enviada, então não foi enviada de novo.",
|
||||
"Send invites": "Enviar convites",
|
||||
"Send scheduled for {when}": "Envio agendado para {when}",
|
||||
"Send without a subject?": "Enviar sem assunto?",
|
||||
|
||||
@@ -645,6 +645,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакты",
|
||||
|
||||
"Collected": "Собранные",
|
||||
|
||||
"Save people you write to as contacts": "Сохранять адресатов как контакты",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
|
||||
"Contacts are not available": "Контакты недоступны",
|
||||
"New contact": "Новый контакт",
|
||||
"Edit contact": "Изменить контакт",
|
||||
@@ -1332,6 +1338,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Всё равно отправить",
|
||||
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
||||
"Send failed: {error}": "Не удалось отправить: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не удалось проверить, было ли это письмо уже отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не удалось подтвердить, было ли это письмо отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Это письмо уже было отправлено, поэтому повторно оно не отправлялось.",
|
||||
"Send invites": "Отправить приглашения",
|
||||
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
||||
"Send without a subject?": "Отправить без темы?",
|
||||
|
||||
+97
-22
@@ -48,7 +48,6 @@ export const catalog: Catalog = {
|
||||
"A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar’s own sidebar without turning it off here.": "Kişi kartlarınızdaki doğum günlerinden türetilen özel bir takvim. Hiçbir yere bir şey yazılmaz — tarihler kartlarda kalır ve kişi silindiğinde veya doğum günü temizlendiğinde etkinlik kaybolur. Buradan kapatılmadan da takvimin kendi kenar çubuğundan gizlenebilir.",
|
||||
"A calendar published at a URL — a timetable, a rota, a public holiday list. It is read-only, refreshed when you open the calendar, and never stored: the events are fetched and kept only for as long as this tab is open.": "Bir URL'de yayımlanan bir takvim — bir ders programı, nöbet çizelgesi veya resmi tatil listesi. Salt okunurdur, takvimi açtığınızda yenilenir ve asla saklanmaz: etkinlikler yalnızca bu sekme açık olduğu sürece getirilir ve tutulur.",
|
||||
"A denial wins over anything allowed, here or on a role this one builds on. You can only allow permissions you hold yourself.": "Bir ret, burada veya üzerine inşa edildiği bir rolde izin verilen her şeyden üstündür. Yalnızca bizzat sahip olduğunuz izinlere onay verebilirsiniz.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "JMAP üzerine inşa edilmiş, {server} için hızlı, samimi ve açık kaynaklı bir web posta.",
|
||||
"A group needs an address.": "Bir grubun bir adrese ihtiyacı vardır.",
|
||||
"A link whose text names one domain and whose destination is another is always flagged, even where the destination is trusted — being trusted is not the same as being the place the text claimed.": "Metninde bir etki alanı adı yazan ancak hedefi başka bir etki alanı olan bağlantılar, hedef güvenilir olsa bile her zaman işaretlenir — güvenilir olmak metnin iddia ettiği yer olmakla aynı şey değildir.",
|
||||
"A list needs an address.": "Bir listenin bir adrese ihtiyacı vardır.",
|
||||
@@ -147,7 +146,6 @@ export const catalog: Catalog = {
|
||||
"An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts toward its limits, and Administrator means administrator of that tenant.": "Bir hesap, alan adının bulunduğu kiracıda yer alabilir. Bir kiracı içinde, kiracının rolüyle sınırlandırılır ve limitlerine dahil edilir; Yönetici ise o kiracının yöneticisi anlamına gelir.",
|
||||
"An account needs an address.": "Bir hesabın bir adrese ihtiyacı vardır.",
|
||||
"An empty tenant can be deleted.": "Boş bir kiracı silinebilir.",
|
||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Bir görselin https adresi veya data URL'si. Stalwart logo gösterdiği yerlerde bunu kiracının kullanıcılarına gösterir.",
|
||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Gönderenin sunucusundan yüklenen bir görsel, iletinin ne zaman ve kabaca nereden açıldığını gönderene bildirir. Onaylanan görseller tarayıcı yerine {app}'in kendi sunucusu tarafından getirilir, böylece gönderen bunların hiçbirini öğrenemez.",
|
||||
"An occurrence cannot be moved to another calendar on its own": "Bir yineleme tek başına başka bir takvime taşınamaz",
|
||||
"Another name for this domain": "Bu alan adı için başka bir ad",
|
||||
@@ -271,6 +269,9 @@ export const catalog: Catalog = {
|
||||
"Contact options": "Kişi seçenekleri",
|
||||
"Contact saved": "Kişi kaydedildi",
|
||||
"Contacts": "Kişiler",
|
||||
"Collected": "Toplananlar",
|
||||
"Save people you write to as contacts": "Yazdığınız kişileri kişi olarak kaydet",
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Gönderdiğiniz ve henüz kişileriniz arasında olmayan herkes Toplananlar adres defterine eklenir, böylece her cihazda önerilir. Kendi adresleriniz asla eklenmez.",
|
||||
"Contacts are not available": "Kişiler kullanılamıyor",
|
||||
"Continue": "Devam et",
|
||||
"Conversation moved to {folder}": "Yazışma {folder} klasörüne taşındı",
|
||||
@@ -368,8 +369,6 @@ export const catalog: Catalog = {
|
||||
"Defaults for the calendar views and new events.": "Takvim görünümleri ve yeni etkinlikler için varsayılanlar.",
|
||||
"Delete": "Sil",
|
||||
"Delete (#)": "Sil (#)",
|
||||
"Delete account": "Hesabı sil",
|
||||
"Delete account…": "Hesabı sil…",
|
||||
"Delete all spam now": "Tüm spam'i şimdi sil",
|
||||
"Delete category": "Kategoriyi sil",
|
||||
"Delete failed: {error}": "Silme başarısız: {error}",
|
||||
@@ -403,7 +402,6 @@ export const catalog: Catalog = {
|
||||
"Deleted {address}": "{address} silindi",
|
||||
"Deleted {name}": "{name} silindi",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Grubu ve posta kutusunu siler. Üyelerin kendi hesapları kalır.",
|
||||
"Deletes the mailbox and everything in it.": "Posta kutusunu ve içindeki her şeyi siler.",
|
||||
"Deletes the whole series. This cannot be undone.": "Tüm diziyi siler. Bu işlem geri alınamaz.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Bir grubu silmek önce üyelerini gruptan çıkarır ve rolünüz bu üyelerin hesaplarını değiştiremez.",
|
||||
"Deleting spam is permanent — it does not go to Deleted Items first.": "Spam silme kalıcıdır; önce Çöp Kutusuna gitmez.",
|
||||
@@ -413,7 +411,6 @@ export const catalog: Catalog = {
|
||||
"Deny": "Reddet",
|
||||
"Description": "Açıklama",
|
||||
"Desktop notifications while {app} is open": "{app} açıkken masaüstü bildirimleri",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Ayrıntılı metrikler, teslimat kuyruğu, günlükler ve sunucu ayarları Stalwart'ın kendi yönetim panelindedir.",
|
||||
"Details": "Ayrıntılar",
|
||||
"Device": "Cihaz",
|
||||
"Did you forget the attachment?": "Eki unuttunuz mu?",
|
||||
@@ -425,7 +422,6 @@ export const catalog: Catalog = {
|
||||
"Dismiss": "Kapat",
|
||||
"Display density": "Görüntü yoğunluğu",
|
||||
"Display name": "Görünen ad",
|
||||
"Documentation": "Belgeler",
|
||||
"Does not repeat": "Tekrarlanmaz",
|
||||
"Domain": "Alan Adı",
|
||||
"Domain to add": "Eklenecek alan adı",
|
||||
@@ -853,7 +849,6 @@ export const catalog: Catalog = {
|
||||
"Only the beginning is shown — download the file for the rest.": "Yalnızca başlangıç gösteriliyor — geri kalanı için dosyayı indirin.",
|
||||
"Only when it has unread mail": "Yalnızca okunmamış posta olduğunda",
|
||||
"Open": "Aç",
|
||||
"Open Stalwart admin": "Stalwart yönetimini aç",
|
||||
"Open a link to {domain}?": "{domain} bağlantısı açılsın mı?",
|
||||
"Open draft": "Taslağı aç",
|
||||
"Open in calendar": "Takvimde aç",
|
||||
@@ -1067,6 +1062,9 @@ export const catalog: Catalog = {
|
||||
"Send at": "Şu saatte gönder",
|
||||
"Send canceled — the message is back in Drafts": "Gönderim iptal edildi — ileti tekrar Taslaklar içinde",
|
||||
"Send failed: {error}": "Gönderme başarısız: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Bu iletinin daha önce gönderilip gönderilmediği denetlenemedi. Yeniden göndermeden önce Gönderilenler klasörüne bakın.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Bu iletinin gönderilip gönderilmediği doğrulanamadı. Yeniden göndermeden önce Gönderilenler klasörüne bakın.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Bu ileti zaten gönderilmişti, bu yüzden yeniden gönderilmedi.",
|
||||
"Send invitation emails to guests": "Konuklara e-posta ile davetiye gönder",
|
||||
"Send invites": "Davetleri gönder",
|
||||
"Send now instead": "Bunun yerine şimdi gönder",
|
||||
@@ -1156,13 +1154,6 @@ export const catalog: Catalog = {
|
||||
"Source": "Kaynak",
|
||||
"Spam filter": "Spam filtresi",
|
||||
"Spell check while typing": "Yazarken yazım denetimi yap",
|
||||
"Stalwart": "Stalwart",
|
||||
"Stalwart Mail Server": "Stalwart Mail Server",
|
||||
"Stalwart does not publish its version number to mail clients, so {app} reports the edition where the server gives one. {app} requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart sürüm numarasını posta istemcilerine yayımlamaz, bu nedenle {app} sunucunun belirttiği sürümü bildirir. {app}, 0.16 veya daha yeni bir sürüm gerektirir ve oturum açma daha eski sürümleri reddeder.",
|
||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart bu rolü varsayılan olarak {kinds} grubuna verir. Buradaki bir değişiklik bu şekilde sahip olan herkese ulaşır.",
|
||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart bu rolü varsayılan olarak verir, bu nedenle silinemez. Önce Stalwart'ın kendi yönetim panelindeki varsayılanları değiştirin.",
|
||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart bir limitin izin verdiğinden fazlasını oluşturmayı reddeder. Boş bir alan limit olmadığı anlamına gelir.",
|
||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Stalwart'ın izin listesi yüklenemedi, bu nedenle izinler buradan değiştirilemez. ({reason})",
|
||||
"Star": "Yıldız ekle",
|
||||
"Star / unstar": "Yıldız ekle / kaldır",
|
||||
"Starred": "Yıldızlı",
|
||||
@@ -1210,7 +1201,6 @@ export const catalog: Catalog = {
|
||||
"Templates": "Şablonlar",
|
||||
"Tenant": "Kiracı (Tenant)",
|
||||
"Tenants": "Kiracılar (Tenants)",
|
||||
"Tenants are a Stalwart Enterprise feature.": "Kiracılar bir Stalwart Enterprise özelliğidir.",
|
||||
"Tentative": "Belirsiz / Taslak",
|
||||
"Test notification": "Test bildirimi",
|
||||
"Text color": "Metin rengi",
|
||||
@@ -1288,14 +1278,12 @@ export const catalog: Catalog = {
|
||||
"This account does not have the JMAP file storage capability.": "Bu hesap JMAP dosya depolama yeteneğine sahip değil.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Bu hesabın şifresi yok. Bir dizin veya tek oturum açma (SSO) yoluyla giriş yapıyor olabilir.",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Bu hesap sizin sahip olmadığınız izinlere sahiptir; bu nedenle görüntüleyebilirsiniz ancak değiştiremezsiniz.",
|
||||
"This account has permissions yours doesn't.": "Bu hesap sizin sahip olmadığınız izinlere sahiptir.",
|
||||
"This account has two-factor authentication on. {app} can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Bu hesapta iki adımlı doğrulama etkindir. {app} henüz bir kodla giriş yapmanızı desteklemiyor, bu nedenle başka bir cihazda oturum açmak bir uygulama şifresi gerektirir — veya iki adımlı doğrulamayı buradan kapatabilirsiniz.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Bu hesap artık mevcut değil. Biri silmiş olabilir.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Bu hesap harici bir dizin üzerinden giriş yapar, bu nedenle şifresi buradan ayarlanamaz.",
|
||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make {app} the default from your operating system if you install it as an app.": "Bu tarayıcı {scheme} bağlantıları için uygulama kaydedemiyor. Özellikle Safari'de böyle bir API yoktur — bir uygulama olarak yüklerseniz {app} uygulamasını işletim sisteminizden yine de varsayılan yapabilirsiniz.",
|
||||
"This cannot be undone.": "Bu işlem geri alınamaz.",
|
||||
"This date cannot be changed on its own": "Bu tarih tek başına değiştirilemez",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Bu işlem bu hesaptaki e-postaları, takvimleri, kişileri ve dosyaları siler. Sunucu bunları arka planda kaldırır ve bu işlem geri alınamaz.",
|
||||
"This domain is disabled on the server.": "Bu alan adı sunucuda devre dışı bırakılmış.",
|
||||
"This domain no longer exists. Someone may have removed it.": "Bu alan adı artık mevcut değil. Başka biri kaldırmış olabilir.",
|
||||
"This file is not UTF-8 text, so editing it here would corrupt it — download it instead.": "Bu dosya UTF-8 metni değil, bu nedenle burada düzenlemek dosyayı bozabilir — bunun yerine dosyayı indirin.",
|
||||
@@ -1418,12 +1406,10 @@ export const catalog: Catalog = {
|
||||
"You": "Siz",
|
||||
"You can't change your own group memberships.": "Kendi grup üyeliklerinizi değiştiremezsiniz.",
|
||||
"You can't change your own role.": "Kendi rolünüzü değiştiremezsiniz.",
|
||||
"You can't delete the account you're signed in with.": "Giriş yaptığınız hesabı silemezsiniz.",
|
||||
"You can't give a role permissions your own role doesn't have.": "Bir role kendi rolünüzün sahip olmadığı izinleri veremezsiniz.",
|
||||
"You can't give an account permissions your own role doesn't have.": "Bir hesaba kendi rolünüzün sahip olmadığı izinleri veremezsiniz.",
|
||||
"You can't move your own account into a tenant.": "Kendi hesabınızı bir kiracıya taşıyamazsınız.",
|
||||
"You're all caught up": "Her şeyi yakaladınız",
|
||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "{user} olarak giriş yaptınız. Şifreniz asla tarayıcıda saklanmaz; sunucu Stalwart ile iletişim kurmak için her oturumda şifrelenmiş olarak saklar.",
|
||||
"Your Sieve script has changes that have not been saved.": "Sieve betiğinizde kaydedilmemiş değişiklikler var.",
|
||||
"Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.": "Etkin Sieve betiğiniz el ile yazılmıştır, bu nedenle kurallar otomatik olarak eklenemez. Betiği düzenlemek veya yönetilen kurallara geçmek için {where} bölümünü açın.",
|
||||
"Your active script “{name}” was written by hand.": "Aktif betiğiniz olan “{name}” el ile yazılmış.",
|
||||
@@ -1506,7 +1492,6 @@ export const catalog: Catalog = {
|
||||
"https://": "https://",
|
||||
"https://meet.example.com/…": "https://meet.example.com/…",
|
||||
"https://…": "https://…",
|
||||
"ihasmail.org": "ihasmail.org",
|
||||
"is": "şudur",
|
||||
"is larger than": "şundan büyüktür",
|
||||
"is not": "şu değildir",
|
||||
@@ -1558,7 +1543,6 @@ export const catalog: Catalog = {
|
||||
"year(s)": "yıl",
|
||||
"{app} test": "{app} testi",
|
||||
"{app} will tell you if a later message from this address is signed by anybody else.": "Bu adresten gelen sonraki bir ileti başka biri tarafından imzalanırsa {app} sizi bilgilendirecektir.",
|
||||
"{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "{app}'in kendi sürümü, derlendiği commit'in tarihi ve ardından bu commit'in nereden geldiğidir: {example}, 30 Ağustos 2026 tarihli ve 129 numaralı çekme isteğiyle gelen bir commit'ten derlenmiştir. Bir çekme isteğiyle gelmeyen commit bunun yerine kısa SHA değerini taşır — {sha}. Sürüm bilgisi kasıtlı olarak Stalwart hakkında hiçbir şey söylemez; bu derlemenin sunucudan gereksinim duyduğu şey yukarıdaki satırdır.",
|
||||
"{count} conversations moved to {folder}": "{count} yazışma {folder} klasörüne taşındı",
|
||||
"{count} folders": "{count} klasör",
|
||||
"{email} is now your default identity": "{email} artık varsayılan kimliğiniz",
|
||||
@@ -1589,6 +1573,93 @@ export const catalog: Catalog = {
|
||||
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” devre dışı bırakılacak (silinmeyecek) ve yeni bir “ihasmail” betiği görevi devralacaktır.",
|
||||
"•••": "•••",
|
||||
"+ New folder…": "+ Yeni klasör…",
|
||||
"You no longer have access to {name}. Back to your own mail.": "{name} hesabına artık erişiminiz yok. Kendi postanıza dönün.",
|
||||
"Send or close the message you're writing first.": "Önce yazdığınız iletiyi gönderin veya kapatın.",
|
||||
"Locked account": "Kilitli hesap",
|
||||
"Mail to show": "Gösterilecek posta",
|
||||
"My mail": "Postam",
|
||||
"Read only": "Salt okunur",
|
||||
"Read and organize": "Okuma ve düzenleme",
|
||||
"Full access": "Tam erişim",
|
||||
"Locked account:": "Kilitli hesap:",
|
||||
"You can send as this account": "Bu hesap adına gönderebilirsiniz",
|
||||
"Back to my mail": "Postama dön",
|
||||
"You'll sign in on your mail server's own page.": "Posta sunucunuzun kendi sayfasında oturum açacaksınız.",
|
||||
"You'll enter your password on your mail server's sign-in page.": "Şifrenizi posta sunucunuzun oturum açma sayfasına gireceksiniz.",
|
||||
"This sign-in didn't start in this browser. Try again.": "Bu oturum açma işlemi bu tarayıcıda başlamadı. Yeniden deneyin.",
|
||||
"The sign-in took too long. Try again.": "Oturum açma çok uzun sürdü. Yeniden deneyin.",
|
||||
"Sign-in was cancelled.": "Oturum açma iptal edildi.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "Posta sunucusu oturum açmayı kabul etmedi. Yeniden deneyin.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Bu hesap, girdiğiniz adresten farklı bir posta sunucusunda. O adresle oturum açın.",
|
||||
"This mail server isn't supported.": "Bu posta sunucusu desteklenmiyor.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Posta sunucusuna ulaşılamadı. Biraz sonra yeniden deneyin.",
|
||||
"Your password was changed. Sign in with the new one.": "Şifreniz değiştirildi. Yeni şifrenizle oturum açın.",
|
||||
"Your sign-in ended. Sign in again.": "Oturumunuz sona erdi. Yeniden oturum açın.",
|
||||
"{host} does not resolve": "{host} çözümlenemiyor",
|
||||
"unavailable": "kullanılamıyor",
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} eksiksiz bir posta paketidir: bir posta sunucusu, onu yöneten konsol ve bu web posta; her biri kendi programıdır, birlikte kurulur ve AGPL kapsamında ücretsizdir.",
|
||||
"Built on {project}": "{project} üzerine kurulu",
|
||||
"The suite": "Paket",
|
||||
"Mail server": "Posta sunucusu",
|
||||
"Administration console": "Yönetim konsolu",
|
||||
"Webmail": "Web posta",
|
||||
"Webmail node": "Web posta düğümü",
|
||||
"Mail server node": "Posta sunucusu düğümü",
|
||||
"This webmail works with the inbuxa mail server, and sign-in refuses a server that doesn't offer what it needs.": "Bu web posta inbuxa posta sunucusuyla çalışır ve gereken özellikleri sunmayan bir sunucuda oturum açmayı reddeder.",
|
||||
"{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "{app}'in kendi sürümü, derlendiği commit'in tarihi ve ardından bu commit'in nereden geldiğidir: {example}, 30 Ağustos 2026 tarihli ve 129 numaralı çekme isteğiyle gelen bir commit'ten derlenmiştir. Bir çekme isteğiyle gelmeyen commit bunun yerine kısa SHA değerini taşır — {sha}. Sürüm bilgisi kasıtlı olarak posta sunucusu hakkında hiçbir şey söylemez; bu derlemenin sunucudan gereksinim duyduğu şey yukarıdaki satırdır.",
|
||||
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "{user} olarak giriş yaptınız. Bu web posta şifrenizi hiç görmez: posta sunucunuzdan alınan ve her oturumda şifrelenen bir oturum anahtarı tutar.",
|
||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "{user} olarak giriş yaptınız. Şifreniz asla tarayıcıda saklanmaz; sunucu, posta sunucusuyla iletişim kurmak için onu her oturumda şifrelenmiş olarak saklar.",
|
||||
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Kuruluşunuz yalnızca {app} ve JMAP uygulamalarına izin veriyor; bu nedenle telefon ve masaüstü posta uygulamaları bu hesaba bağlanamaz.",
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Kuruluşunuz posta uygulamaları için {protocols} protokollerini kapattı. Bunları kullanan posta uygulamaları bu hesaba bağlanamaz; diğerleri bağlanmaya devam eder.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Şifrenizi değiştirmek, buradaki dahil her yerdeki oturumunuzu kapatır ve yeni şifrenizle yeniden oturum açarsınız. Uygulama şifreleri çalışmaya devam eder.",
|
||||
"One of several signals the spam filter weighed": "İstenmeyen posta filtresinin değerlendirdiği birkaç işaretten biri",
|
||||
"Language model's opinion": "Dil modelinin görüşü",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Bu hesap size okumanız için verildi. İçindeki hiçbir şey değiştirilemez.",
|
||||
"You can file and move mail in this account, but not delete it.": "Bu hesaptaki postaları dosyalayıp taşıyabilirsiniz, ancak silemezsiniz.",
|
||||
"Send anyway?": "Yine de gönderilsin mi?",
|
||||
"Your reason is recorded with the message.": "Gerekçeniz iletiyle birlikte kaydedilir.",
|
||||
"Why this needs to go": "Bunun neden gönderilmesi gerekiyor",
|
||||
"Not sent yet:": "Henüz gönderilmedi:",
|
||||
"Not sent:": "Gönderilmedi:",
|
||||
"Send anyway…": "Yine de gönder…",
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Silme, kilitleme ve yasal saklama işlemleri yönetim konsolunda yapılır; konsol gerekçesini kaydeder ve saklamanın kapsadığı her şeyi korur.",
|
||||
"Open in the console": "Konsolda aç",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Kuruluşunuz için eski posta protokolleri kapalı. Yalnızca {app} ve JMAP uygulamaları oturum açabilir.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Kuruluşunuz için bazı eski posta protokolleri kapalı: {protocols}.",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Ayrıntılı metrikler, teslimat kuyruğu, günlükler ve sunucu ayarları inbuxa Admin'dedir.",
|
||||
"Open inbuxa Admin": "inbuxa Admin'i aç",
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Posta sunucusu bu rolü varsayılan olarak {kinds} grubuna verir. Buradaki bir değişiklik bu şekilde sahip olan herkese ulaşır.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Posta sunucusu bu rolü varsayılan olarak verir, bu nedenle silinemez. Önce inbuxa Admin'de varsayılanları değiştirin.",
|
||||
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Posta sunucusunun izin listesi yüklenemedi, bu nedenle izinler buradan değiştirilemez. ({reason})",
|
||||
"Legacy mail apps": "Eski posta uygulamaları",
|
||||
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "{tenant} için kapalı. Alan adlarında yalnızca {app} ve JMAP uygulamaları oturum açabilir.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} için bazıları kapalı: {protocols}. Bunları yönetim konsolundan tek tek değiştirin.",
|
||||
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "{tenant} için açık. Posta uygulamaları alan adlarında IMAP, POP3 ve ManageSieve kullanabilir.",
|
||||
"Turn legacy protocols back on": "Eski protokolleri yeniden aç",
|
||||
"Turn off legacy protocols…": "Eski protokolleri kapat…",
|
||||
"No account used a legacy mail app in the last 30 days.": "Son 30 günde hiçbir hesap eski bir posta uygulaması kullanmadı.",
|
||||
"Their mail apps will stop working the moment you turn this on:": "Bunu açtığınız anda posta uygulamaları çalışmayı bırakır:",
|
||||
"Only {app} and JMAP apps will work.": "Yalnızca {app} ve JMAP uygulamaları çalışır.",
|
||||
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Eski posta protokolleri (IMAP, POP3, ManageSieve ve posta uygulamalarından gönderme) {tenant} içindeki herkes için kapatılacak.",
|
||||
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Telefon ve masaüstü posta uygulamaları posta alıp göndermeyi bırakacak. Buna iPhone ve iPad Mail, Gmail ve Outlook uygulamaları, Outlook, Thunderbird ve Apple Mail dahildir. Kullanıcılar bu uygulamalarda oturum açma hataları görecek.",
|
||||
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Bir posta uygulamasından yönetilen filtreler (ManageSieve) çalışmayı bırakacak. {app} içinde ayarlanan filtreler çalışmaya devam eder.",
|
||||
"Incoming mail is not affected. Calendars and contacts are not affected.": "Gelen posta etkilenmez. Takvimler ve kişiler etkilenmez.",
|
||||
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Kullanıcılar, telefonlara ve bilgisayarlara uygulama olarak kurulabilen {app} üzerinden tam erişimini korur.",
|
||||
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "Posta uygulamalarından gönderme (SMTP gönderimi) çalışmayı bırakacak, ancak bağlantı noktaları açık kalır: posta uygulamalarına oturum açamayacakları bildirilir. Gelen posta (SMTP) ve {app} (JMAP) etkilenmez ve buradan kapatılamaz.",
|
||||
"You can turn legacy protocols back on at any time.": "Eski protokolleri istediğiniz zaman yeniden açabilirsiniz.",
|
||||
"To confirm, type {phrase}": "Onaylamak için {phrase} yazın",
|
||||
"Turn off legacy protocols": "Eski protokolleri kapat",
|
||||
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Bir görselin https adresi veya data URL'si. Posta sunucusu logo gösterdiği yerlerde bunu kiracının kullanıcılarına gösterir.",
|
||||
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "Posta sunucusu bir limitin izin verdiğinden fazlasını oluşturmayı reddeder. Boş bir alan limit olmadığı anlamına gelir.",
|
||||
"This message wasn't sent under the server's rules": "Bu ileti sunucunun kuralları gereği gönderilmedi",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "{name} adına gönderemezsiniz. Bu hesap size gönderim için değil, okumanız için verildi.",
|
||||
"Held for review: it's sent once a reviewer releases it": "İnceleme için bekletiliyor: bir inceleyici onayladığında gönderilecek",
|
||||
"Not sent yet: check the warning": "Henüz gönderilmedi: uyarıyı inceleyin",
|
||||
"Not sent: blocked by the server's rules": "Gönderilmedi: sunucunun kuralları tarafından engellendi",
|
||||
"IMAP": "IMAP",
|
||||
"POP3": "POP3",
|
||||
"ManageSieve": "ManageSieve",
|
||||
"SMTP": "SMTP",
|
||||
},
|
||||
plurals: {
|
||||
"({n} deleted first)": {
|
||||
@@ -1843,5 +1914,9 @@ export const catalog: Catalog = {
|
||||
one: "{rule}, {n} kez",
|
||||
other: "{rule}, {n} kez",
|
||||
},
|
||||
"{n} accounts used a legacy mail app in the last 30 days.": {
|
||||
one: "Son 30 günde {n} hesap eski bir posta uygulaması kullandı.",
|
||||
other: "Son 30 günde {n} hesap eski bir posta uygulaması kullandı.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -639,6 +639,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакти",
|
||||
|
||||
"Collected": "Зібрані",
|
||||
|
||||
"Save people you write to as contacts": "Зберігати адресатів як контакти",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
|
||||
"Contacts are not available": "Контакти недоступні",
|
||||
"New contact": "Новий контакт",
|
||||
"Edit contact": "Змінити контакт",
|
||||
@@ -1326,6 +1332,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Усе одно надіслати",
|
||||
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
|
||||
"Send failed: {error}": "Не вдалося надіслати: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не вдалося перевірити, чи цей лист уже надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не вдалося підтвердити, чи цей лист надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Цей лист уже було надіслано, тому повторно його не надсилали.",
|
||||
"Send invites": "Надіслати запрошення",
|
||||
"Send scheduled for {when}": "Надсилання заплановано на {when}",
|
||||
"Send without a subject?": "Надіслати без теми?",
|
||||
|
||||
@@ -641,6 +641,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "还有 {n} 项",
|
||||
|
||||
"Contacts": "联系人",
|
||||
|
||||
"Collected": "已收集",
|
||||
|
||||
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
|
||||
"Contacts are not available": "联系人不可用",
|
||||
"New contact": "新建联系人",
|
||||
"Edit contact": "编辑联系人",
|
||||
@@ -1337,6 +1343,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "仍然发送",
|
||||
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
|
||||
"Send failed: {error}": "发送失败:{error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "无法检查这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "无法确认这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"This message had already been sent, so it wasn't sent again.": "这封邮件已经发送过,因此没有再次发送。",
|
||||
"Send invites": "发送邀请",
|
||||
"Send scheduled for {when}": "已定时于 {when} 发送",
|
||||
"Send without a subject?": "不填主题就发送吗?",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
|
||||
|
||||
function okServer() {
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
|
||||
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
let collect: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
|
||||
});
|
||||
collect = vi.fn(async () => 1);
|
||||
useContacts.setState({ collectRecipients: collect as never });
|
||||
okServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
|
||||
await useCompose.getState().send(key);
|
||||
};
|
||||
|
||||
describe("collecting recipients when a message is sent", () => {
|
||||
it("passes every recipient, and the sender's own addresses to leave out", async () => {
|
||||
await send();
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
const [addrs, own] = collect.mock.calls[0]!;
|
||||
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(own).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not when the setting is off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
|
||||
await send();
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { ContactCard, JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* People written to used to be remembered only in this browser, as a list of
|
||||
* recent addresses, so a new device suggested nobody. Each confirmed send now
|
||||
* saves the recipients who are not contacts yet, in an address book of their
|
||||
* own, on the server.
|
||||
*/
|
||||
|
||||
interface Calls {
|
||||
books: Record<string, unknown>[];
|
||||
cards: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function server(): Calls {
|
||||
const calls: Calls = { books: [], cards: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "AddressBook/set" && args.create) {
|
||||
calls.books.push(args.create as Record<string, unknown>);
|
||||
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
|
||||
}
|
||||
if (name === "ContactCard/set" && args.create) {
|
||||
const create = args.create as Record<string, unknown>;
|
||||
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const card = (id: string, email: string): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
|
||||
primaryAccounts: { [CAP.contacts]: "a1" },
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: true,
|
||||
loaded: true,
|
||||
books: { b1: { id: "b1", name: "Personal" } as never },
|
||||
cards: { c1: card("c1", "[email protected]") },
|
||||
sharedCards: {},
|
||||
syncCards: (async () => undefined) as never,
|
||||
loadBooks: (async () => undefined) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("saving the people written to", () => {
|
||||
it("adds only addresses that are not contacts yet, and never your own", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients(
|
||||
[
|
||||
{ name: "Known Person", email: "[email protected]" },
|
||||
{ name: "Smith, Jane", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Jane again", email: "[email protected]" },
|
||||
],
|
||||
["[email protected]"],
|
||||
);
|
||||
expect(n).toBe(1);
|
||||
expect(calls.cards).toHaveLength(1);
|
||||
const added = calls.cards[0]!;
|
||||
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
||||
// Filed under the Collected book, with the name split the way the editor does.
|
||||
expect(added.addressBookIds).toEqual({ bNew: true });
|
||||
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
|
||||
});
|
||||
|
||||
it("creates the Collected book once and remembers it", async () => {
|
||||
const calls = server();
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
|
||||
});
|
||||
|
||||
it("makes a new book when the remembered one has been deleted", async () => {
|
||||
const calls = server();
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing, and makes no book, when everyone is already a contact", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(n).toBe(0);
|
||||
expect(calls.books).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* What the recipient field offers as you type. Matching used to take the
|
||||
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
|
||||
* and a nickname or a company name found nothing at all.
|
||||
*/
|
||||
|
||||
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
principalsLoaded: true,
|
||||
principals: [],
|
||||
sharedCards: {},
|
||||
recent: [],
|
||||
cards: {
|
||||
c1: card("c1", "Smith, Jane", "[email protected]"),
|
||||
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
|
||||
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
|
||||
c4: card("c4", "Ann Taylor", "[email protected]"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
|
||||
|
||||
describe("recipient suggestions", () => {
|
||||
it("matches the words typed in any order", async () => {
|
||||
expect(await emails("jane smi")).toEqual(["[email protected]"]);
|
||||
expect(await emails("smi jan")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not match when one of the words fits nobody", async () => {
|
||||
expect(await emails("jane xyz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a nickname and an organization", async () => {
|
||||
expect(await emails("bobby")).toEqual(["[email protected]"]);
|
||||
expect(await emails("globex")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("puts someone written to lately ahead of an equal match", async () => {
|
||||
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
|
||||
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
|
||||
expect((await emails("ann"))[0]).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("still ranks a match at the start above a word inside the name", async () => {
|
||||
// "jo" starts Jones's surname word; it starts no other card's name or address.
|
||||
expect(await emails("jo")).toEqual(["[email protected]"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, CAP, client } from "@/jmap/client";
|
||||
import { sendOutcomeUnknown, useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import { useToasts } from "@/ui/toast";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* A send whose reply goes missing may still have gone out. "Send failed ->
|
||||
* Open draft -> Send" then delivered it twice: nothing said which message
|
||||
* the first attempt was, so nothing could check. Each send now carries its
|
||||
* own Message-ID, and an unanswered send asks the server what happened to it
|
||||
* before calling it failed.
|
||||
*/
|
||||
|
||||
type Mode = "ok" | "lost-reply" | "never-arrived" | "orphan" | "gateway" | "refused" | "offline-after";
|
||||
|
||||
interface Server {
|
||||
emails: { id: string; messageId: string }[];
|
||||
submissions: { id: string; emailId: string }[];
|
||||
destroyed: string[];
|
||||
creates: number;
|
||||
}
|
||||
|
||||
function server(mode: Mode): Server {
|
||||
const st: Server = { emails: [], submissions: [], destroyed: [], creates: 0 };
|
||||
let first = true;
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const sending = body.methodCalls.some(([n]) => n === "EmailSubmission/set");
|
||||
if (sending && first && mode !== "ok") {
|
||||
first = false;
|
||||
if (mode === "never-arrived") throw new TypeError("Failed to fetch");
|
||||
if (mode === "gateway") return { ok: false, status: 502, statusText: "Bad Gateway", json: async () => ({ error: "bad_gateway" }) } as Response;
|
||||
if (mode === "refused") return { ok: false, status: 400, statusText: "Bad Request", json: async () => ({ type: "urn:ietf:params:jmap:error:notRequest" }) } as Response;
|
||||
// The server does the work; only the answer is lost.
|
||||
const [, args] = body.methodCalls.find(([n, a]) => n === "Email/set" && (a as { create?: unknown }).create)!;
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const id = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id, messageId: m.messageId[0]! });
|
||||
if (mode !== "orphan") st.submissions.push({ id: `s${id}`, emailId: id });
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (mode === "offline-after" && !first) throw new TypeError("Failed to fetch");
|
||||
if (mode === "offline-after" && sending) {
|
||||
first = false;
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && (args as { create?: unknown }).create) {
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const eid = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id: eid, messageId: m.messageId[0]! });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: eid } } }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/set") {
|
||||
const eid = st.emails[st.emails.length - 1]!.id;
|
||||
st.submissions.push({ id: `s${eid}`, emailId: eid });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: `s${eid}` } } }, id];
|
||||
}
|
||||
if (name === "Email/set" && (args as { destroy?: string[] }).destroy) {
|
||||
st.destroyed.push(...((args as { destroy: string[] }).destroy));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: (args as { destroy: string[] }).destroy }, id];
|
||||
}
|
||||
if (name === "Email/query") {
|
||||
const [, value] = ((args.filter ?? {}) as { header?: [string, string] }).header ?? [];
|
||||
const ids = value ? st.emails.filter((e) => e.messageId === value && !st.destroyed.includes(e.id)).map((e) => e.id) : [];
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/query") {
|
||||
const want = ((args.filter ?? {}) as { emailIds?: string[] }).emailIds ?? [];
|
||||
const ids = st.submissions.filter((s) => want.includes(s.emailId)).map((s) => s.id);
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return st;
|
||||
}
|
||||
|
||||
const toastTexts = () => useToasts.getState().toasts.map((t) => t.message);
|
||||
|
||||
async function sendOne(init: Record<string, unknown> = {}) {
|
||||
const key = useCompose.getState().open({ to: [{ name: null, email: "[email protected]" }], subject: "Hi", ...init });
|
||||
await useCompose.getState().send(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: {
|
||||
mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" },
|
||||
mbDrafts: { id: "mbDrafts", role: "drafts", parentId: null, name: "Drafts" },
|
||||
} as never,
|
||||
});
|
||||
useToasts.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("which failures leave the outcome open", () => {
|
||||
it("treats a refusal as not sent, and no answer or a gateway error as unknown", () => {
|
||||
expect(sendOutcomeUnknown(new ApiError(400, "bad"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(401, "unauthenticated"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(502, "bad_gateway"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new ApiError(408, "timeout"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new TypeError("Failed to fetch"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending once, whatever goes wrong on the way back", () => {
|
||||
it("gives every send a Message-ID on the sender's domain", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(1);
|
||||
expect(st.emails[0]!.messageId).toMatch(/^[0-9a-f-]{36}@example\.org$/);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("calls it sent when the reply was lost but the server sent it", async () => {
|
||||
const st = server("lost-reply");
|
||||
await sendOne();
|
||||
expect(st.submissions).toHaveLength(1);
|
||||
expect(st.destroyed).toEqual([]);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
expect(useCompose.getState().drafts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a message that was created but never submitted, and says it failed", async () => {
|
||||
const st = server("orphan");
|
||||
await sendOne();
|
||||
expect(st.destroyed).toEqual(["e1"]);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("says it failed when the request never reached the server", async () => {
|
||||
const st = server("never-arrived");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("asks the server after a gateway error too, rather than assuming", async () => {
|
||||
const st = server("gateway");
|
||||
await sendOne();
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not ask after a refusal: the server did not run it", async () => {
|
||||
server("refused");
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
await sendOne();
|
||||
const asked = fetchMock.mock.calls.some(([, init]) => (init as RequestInit).body?.toString().includes("Email/query"));
|
||||
expect(asked).toBe(false);
|
||||
});
|
||||
|
||||
it("says plainly when it cannot tell, instead of offering a resend that could duplicate", async () => {
|
||||
server("offline-after");
|
||||
await sendOne();
|
||||
const msgs = toastTexts();
|
||||
expect(msgs.some((t) => t.includes("Couldn't confirm whether this message was sent"))).toBe(true);
|
||||
expect(msgs.some((t) => t.startsWith("Send failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending a draft again after a failure", () => {
|
||||
it("keeps the Message-ID, and sends nothing when the first attempt went out", async () => {
|
||||
const st = server("ok");
|
||||
// The first attempt went out; the client never heard.
|
||||
st.emails.push({ id: "e1", messageId: "[email protected]" });
|
||||
st.submissions.push({ id: "se1", emailId: "e1" });
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.includes("had already been sent"))).toBe(true);
|
||||
});
|
||||
|
||||
it("sends it, under the same Message-ID, when the first attempt did not go out", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(1);
|
||||
expect(st.emails[0]!.messageId).toBe("[email protected]");
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("reopens a failed draft with its Message-ID, so the next send can check", async () => {
|
||||
server("orphan");
|
||||
await sendOne();
|
||||
const toast = useToasts.getState().toasts.find((t) => t.message.startsWith("Send failed"))!;
|
||||
toast.action!.onClick();
|
||||
const reopened = useCompose.getState().drafts[0]!;
|
||||
expect(reopened.sendMessageId).toMatch(/@example\.org$/);
|
||||
});
|
||||
});
|
||||
+120
-6
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import { ApiError, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
@@ -102,6 +102,12 @@ export interface Draft {
|
||||
dlp?: DlpRefusalInfo | null;
|
||||
/** The reason to send despite a DLP warning, for the next send only. */
|
||||
dlpOverride?: string | null;
|
||||
/**
|
||||
* The Message-ID this draft goes out under, fixed at its first send and
|
||||
* kept if the draft comes back after a failure. Sending it again asks the
|
||||
* server first whether a message with this id already went out.
|
||||
*/
|
||||
sendMessageId?: string;
|
||||
}
|
||||
|
||||
export interface DlpRefusalInfo {
|
||||
@@ -738,8 +744,12 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
},
|
||||
|
||||
async send(key) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const found = get().drafts.find((x) => x.key === key);
|
||||
if (!found) return;
|
||||
// A draft that already has a Message-ID has been sent before, and failed.
|
||||
const retry = Boolean(found.sendMessageId);
|
||||
const fromEmail = (useMail.getState().identities.find((i) => i.id === found.identityId) ?? useMail.getState().identities[0])?.email ?? "";
|
||||
const d: Draft = { ...found, sendMessageId: found.sendMessageId ?? newMessageId(fromEmail) };
|
||||
const delay = settings().undoSendSeconds;
|
||||
// A schedule the user left sitting until it passed is just a send now.
|
||||
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
|
||||
@@ -754,7 +764,18 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
return { pendingSends: rest };
|
||||
});
|
||||
try {
|
||||
const sent = await sendInternal(d, get);
|
||||
const sent = await sendInternal(d, get, { retry });
|
||||
// Everyone written to who is not a contact yet, so they are suggested on every
|
||||
// device. Also when a resend found the first attempt had gone out: its reply
|
||||
// was what got lost, so nothing was collected then.
|
||||
if (settings().collectRecipients) {
|
||||
const own = useMail.getState().identities.map((i) => i.email);
|
||||
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
|
||||
}
|
||||
if (sent.alreadySent) {
|
||||
toast.success(translate("This message had already been sent, so it wasn't sent again."));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
sent.held
|
||||
? translate("Held for review: it's sent once a reviewer releases it")
|
||||
@@ -769,6 +790,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
|
||||
return;
|
||||
}
|
||||
if (err instanceof SendOutcomeUnknown) {
|
||||
toast.error(err.message, {
|
||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: err.message }], activeKey: d.key })) },
|
||||
duration: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
|
||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
||||
duration: 15000,
|
||||
@@ -1082,9 +1110,54 @@ export function buildSubmission(opts: {
|
||||
/** What the server said of a send: whether DLP held it for review (inbuxa). */
|
||||
interface Sent {
|
||||
held: boolean;
|
||||
/** A resend found the first attempt had already gone out; nothing was sent again. */
|
||||
alreadySent?: boolean;
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
||||
/**
|
||||
* A send whose outcome nobody can state: the request went out, no answer came
|
||||
* back, and the server could not be asked afterwards either. Offering a plain
|
||||
* "send again" here is how one message is delivered twice.
|
||||
*/
|
||||
export class SendOutcomeUnknown extends Error {}
|
||||
|
||||
/** A Message-ID for one send, on the sending identity's domain (RFC 5322 §3.6.4). */
|
||||
export function newMessageId(fromEmail: string): string {
|
||||
const domain = fromEmail.split("@")[1] || "localhost";
|
||||
return `${crypto.randomUUID()}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed request may still have been carried out. A refusal (4xx)
|
||||
* means the server did not run it; no answer, or a 5xx from the proxy
|
||||
* that lost the upstream reply, means it may have.
|
||||
*/
|
||||
export function sendOutcomeUnknown(err: unknown): boolean {
|
||||
if (err instanceof ApiError) return err.status >= 500 || err.status === 408;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of a send with this Message-ID.
|
||||
*
|
||||
* `sent`: a message carrying it exists and was submitted. `orphan`: it exists
|
||||
* but no submission does, so it was created and never sent. `none`: nothing.
|
||||
*
|
||||
* Submissions are expunged after the server's hold period, so "no submission"
|
||||
* means unsent only right after the attempt (`justNow`). Later, a message
|
||||
* still carrying the id is taken as sent: every path that fails a send
|
||||
* destroys the copy it created.
|
||||
*/
|
||||
export async function traceSend(accountId: Id, messageId: string, justNow: boolean): Promise<{ trace: "sent" | "orphan" | "none"; emailIds: Id[] }> {
|
||||
const q = await client.call<{ ids: Id[] }>("Email/query", { accountId, filter: { header: ["Message-ID", messageId] } });
|
||||
const emailIds = q.ids ?? [];
|
||||
if (!emailIds.length) return { trace: "none", emailIds };
|
||||
if (!justNow) return { trace: "sent", emailIds };
|
||||
const subs = await client.call<{ ids: Id[] }>("EmailSubmission/query", { accountId, filter: { emailIds } });
|
||||
return { trace: subs.ids?.length ? "sent" : "orphan", emailIds };
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState, opts: { retry?: boolean } = {}): Promise<Sent> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
@@ -1093,6 +1166,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
||||
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
|
||||
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
|
||||
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
|
||||
const messageId = d.sendMessageId ?? newMessageId(ident.email);
|
||||
email.messageId = [messageId];
|
||||
/*
|
||||
* Sending again a draft that came back from a failed send: ask first. The
|
||||
* failure may have been only the reply going missing, and a second
|
||||
* submission would deliver the message twice.
|
||||
*/
|
||||
if (opts.retry) {
|
||||
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||
try {
|
||||
found = await traceSend(accountId, messageId, false);
|
||||
} catch {
|
||||
throw new SendOutcomeUnknown(translate("Couldn't check whether this message was already sent. Check Sent before sending it again."));
|
||||
}
|
||||
if (found.trace === "sent") {
|
||||
if (d.draftId) void client.call("Email/set", { accountId, destroy: [d.draftId] });
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: false, alreadySent: true };
|
||||
}
|
||||
}
|
||||
const sentId = mail.roleId("sent");
|
||||
const draftsId = mail.roleId("drafts");
|
||||
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
||||
@@ -1119,7 +1213,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
||||
if (d.relatedEmailId && d.relatedKeyword) {
|
||||
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
|
||||
}
|
||||
const res = await client.chain(calls, { allowErrors: true });
|
||||
let res: Awaited<ReturnType<typeof client.chain>>;
|
||||
try {
|
||||
res = await client.chain(calls, { allowErrors: true });
|
||||
} catch (err) {
|
||||
if (!sendOutcomeUnknown(err)) throw err;
|
||||
// No answer is not a no: find out what the server did with it.
|
||||
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||
try {
|
||||
found = await traceSend(accountId, messageId, true);
|
||||
} catch {
|
||||
throw new SendOutcomeUnknown(translate("Couldn't confirm whether this message was sent. Check Sent before sending it again."));
|
||||
}
|
||||
if (found.trace === "sent") {
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: false };
|
||||
}
|
||||
// Created but never submitted: take it out of Sent, then report the failure.
|
||||
if (found.trace === "orphan") void client.call("Email/set", { accountId, destroy: found.emailIds });
|
||||
throw err;
|
||||
}
|
||||
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
|
||||
if (e.__error) throw new Error(setErrorMessage(e.__error));
|
||||
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
|
||||
|
||||
@@ -2,10 +2,12 @@ import { create } from "zustand";
|
||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
|
||||
import { uniqueAddresses } from "@/lib/address";
|
||||
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import { useSettings } from "./settings";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
@@ -225,6 +227,8 @@ interface ContactsState {
|
||||
*/
|
||||
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
|
||||
createBook(name: string): Promise<Id>;
|
||||
/** Save the addresses that are not on any contact yet, in the Collected address book. */
|
||||
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
|
||||
@@ -616,6 +620,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return { destroyed: gone.length, unfiled, refused };
|
||||
},
|
||||
|
||||
async collectRecipients(addrs, own) {
|
||||
if (!get().available || !get().accountId) return 0;
|
||||
// Every card has to be known, or someone already a contact gets a second card.
|
||||
if (!get().loaded) await get().loadAll();
|
||||
const ownSet = new Set(own.map((e) => e.toLowerCase()));
|
||||
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
|
||||
if (!fresh.length) return 0;
|
||||
const accountId = get().accountId!;
|
||||
const { settings, update } = useSettings.getState();
|
||||
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
|
||||
if (!bookId) {
|
||||
bookId = await get().createBook(translate("Collected"));
|
||||
update({ collectedBookId: bookId });
|
||||
}
|
||||
const create = Object.fromEntries(
|
||||
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
|
||||
);
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||
await get().syncCards();
|
||||
return Object.keys(res.created ?? {}).length;
|
||||
},
|
||||
|
||||
async createBook(name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
||||
@@ -793,14 +819,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
seen.add(k);
|
||||
out.push(s);
|
||||
};
|
||||
const score = (name: string | null, email: string): number => {
|
||||
/*
|
||||
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
|
||||
* organization counts as much as the name. Each word typed has to start a
|
||||
* word of the person's name, nickname, organization or address.
|
||||
*/
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
const wordsOf = (parts: Array<string | null | undefined>) =>
|
||||
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
|
||||
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
|
||||
const n = (name ?? "").toLowerCase();
|
||||
const e = email.toLowerCase();
|
||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
||||
const words = wordsOf([name, email, ...extra]);
|
||||
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
|
||||
if (e.includes(q) || n.includes(q)) return 2;
|
||||
return 99;
|
||||
};
|
||||
// Someone written to lately ranks a little above someone not, within the same kind of match.
|
||||
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
|
||||
const recency = (email: string) => {
|
||||
const i = recentRank.get(email.toLowerCase());
|
||||
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
// A shared address book is only useful if it answers when you are writing
|
||||
// to someone in it, so its cards are offered alongside the reader's own.
|
||||
@@ -808,19 +849,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||
for (const { c, penalty } of [...own, ...shared]) {
|
||||
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
||||
const sc = score(a.name, a.email, extra);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
if (!p.email) continue;
|
||||
const sc = score(p.name, p.email);
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
|
||||
}
|
||||
for (const r of st.recent) {
|
||||
const sc = score(r.name, r.email);
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
for (const c of candidates) {
|
||||
|
||||
@@ -206,6 +206,15 @@ export interface Settings {
|
||||
* dates nobody put there is a surprise rather than a feature.
|
||||
*/
|
||||
birthdayCalendar: boolean;
|
||||
/**
|
||||
* Save everyone written to who is not a contact yet, in an address book of
|
||||
* its own, so they are suggested on every device rather than only in the
|
||||
* browser that sent the message. On by default, as mail clients do; the
|
||||
* address book can be emptied or deleted like any other.
|
||||
*/
|
||||
collectRecipients: boolean;
|
||||
/** The address book collected recipients go to, once there is one. */
|
||||
collectedBookId: string | null;
|
||||
/**
|
||||
* Calendars subscribed to by URL. The subscription is the setting; the
|
||||
* events themselves are fetched on demand and never stored, so this follows
|
||||
@@ -361,6 +370,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
timeZone: null,
|
||||
labelsSidebar: true,
|
||||
birthdayCalendar: false,
|
||||
collectRecipients: true,
|
||||
collectedBookId: null,
|
||||
icalSubscriptions: [],
|
||||
listSortPreset: "newest",
|
||||
listSortLevels: [],
|
||||
|
||||
@@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
already knows the name they are half-way through typing. */}
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
|
||||
</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<RecipientInput id={`${key}-to`} value={d.to} exclude={[...d.cc, ...d.bcc]} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<span className="field-extra">
|
||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||
they are thinking about who the message goes to. The label
|
||||
@@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} exclude={[...d.to, ...d.bcc]} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} exclude={[...d.to, ...d.cc]} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
|
||||
@@ -12,9 +12,11 @@ interface Props {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
/** Addresses already in the message's other recipient fields, not to be suggested again. */
|
||||
exclude?: EmailAddress[];
|
||||
}
|
||||
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
@@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
const t = window.setTimeout(() => {
|
||||
void suggest(q).then((list) => {
|
||||
if (id !== reqId.current) return;
|
||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
||||
const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
|
||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||
setSugg(filtered);
|
||||
setActive(0);
|
||||
@@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
});
|
||||
}, 120);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [text, suggest, value]);
|
||||
}, [text, suggest, value, exclude]);
|
||||
|
||||
const commit = (raw?: string) => {
|
||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||
|
||||
@@ -25,6 +25,15 @@ interface Props {
|
||||
imageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Pasted or dropped images larger than this go in as attachments, not inline. */
|
||||
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
|
||||
|
||||
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
|
||||
export function isLinkToPaste(text: string): boolean {
|
||||
if (!text || /\s/.test(text)) return false;
|
||||
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
|
||||
}
|
||||
|
||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||
|
||||
@@ -133,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const f = imgItem.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
insertImageFile(f);
|
||||
// A large image is a file to send, not a picture in the text: it goes
|
||||
// in as an attachment, where an inline copy would swell every reply.
|
||||
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
|
||||
else insertImageFile(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* A link pasted over selected words makes those words the link, the way
|
||||
* most editors do, instead of replacing them with the address.
|
||||
*/
|
||||
const pastedText = e.clipboardData.getData("text/plain").trim();
|
||||
const sel = window.getSelection();
|
||||
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
|
||||
e.preventDefault();
|
||||
document.execCommand("createLink", false, pastedText);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
const htmlData = e.clipboardData.getData("text/html");
|
||||
if (htmlData) {
|
||||
e.preventDefault();
|
||||
@@ -175,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
|
||||
const images = files.filter(inline);
|
||||
const others = files.filter((f) => !inline(f));
|
||||
images.forEach(insertImageFile);
|
||||
if (others.length) onFiles?.(others);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/*
|
||||
* Two things pasting into a message now does: a link pasted over selected
|
||||
* words turns those words into the link, and an image too large to belong
|
||||
* in the text goes in as an attachment. jsdom does no editing, so these check
|
||||
* the command and the callback the editor reaches for.
|
||||
*/
|
||||
|
||||
describe("what counts as a link to paste", () => {
|
||||
it("takes one web or mailto address", () => {
|
||||
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
|
||||
expect(isLinkToPaste("http://example.com")).toBe(true);
|
||||
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary text and anything with spaces alone", () => {
|
||||
expect(isLinkToPaste("see https://example.com")).toBe(false);
|
||||
expect(isLinkToPaste("example.com")).toBe(false);
|
||||
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
|
||||
expect(isLinkToPaste("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasting into the editor", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
|
||||
let exec: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
onFiles = vi.fn<(files: File[]) => void>();
|
||||
exec = vi.fn(() => true);
|
||||
(document as unknown as { execCommand: unknown }).execCommand = exec;
|
||||
await act(async () => {
|
||||
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||
|
||||
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
|
||||
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
|
||||
const ev = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
|
||||
});
|
||||
await act(async () => editor().dispatchEvent(ev));
|
||||
return ev;
|
||||
};
|
||||
|
||||
const select = (word: string) => {
|
||||
const text = editor().querySelector("p")!.firstChild!;
|
||||
const i = text.textContent!.indexOf(word);
|
||||
const r = document.createRange();
|
||||
r.setStart(text, i);
|
||||
r.setEnd(text, i + word.length);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
};
|
||||
|
||||
it("turns selected words into the pasted link", async () => {
|
||||
select("docs");
|
||||
const ev = await paste({ text: "https://example.com/docs" });
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("pastes a link as text when nothing is selected", async () => {
|
||||
window.getSelection()!.removeAllRanges();
|
||||
await paste({ text: "https://example.com/docs" });
|
||||
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it("attaches a pasted image too large to sit in the text", async () => {
|
||||
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
|
||||
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
|
||||
await paste({ files: [big] });
|
||||
expect(onFiles).toHaveBeenCalledWith([big]);
|
||||
});
|
||||
|
||||
it("keeps a normal-sized pasted image in the text", async () => {
|
||||
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
|
||||
await paste({ files: [small] });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RecipientInput } from "../RecipientInput";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* Someone already in Cc was still offered when typing in To, so it was easy
|
||||
* to address the same person twice from two fields.
|
||||
*/
|
||||
|
||||
describe("suggestions across To, Cc and Bcc", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
useContacts.setState({
|
||||
suggest: (async () => [
|
||||
{ name: "Ann Lee", email: "[email protected]", source: "contact" },
|
||||
{ name: "Ann Taylor", email: "[email protected]", source: "contact" },
|
||||
]) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves out an address already in another recipient field", async () => {
|
||||
await act(async () => {
|
||||
root.render(<RecipientInput value={[]} exclude={[{ name: null, email: "[email protected]" }]} onChange={() => {}} />);
|
||||
});
|
||||
const input = host.querySelector("input")!;
|
||||
await act(async () => {
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
set.call(input, "ann");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const offered = host.textContent ?? "";
|
||||
expect(offered).toContain("[email protected]");
|
||||
expect(offered).not.toContain("[email protected]");
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,14 @@ export function CalendarSettings() {
|
||||
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Contacts")}</h2>
|
||||
<Switch
|
||||
checked={s.collectRecipients}
|
||||
onChange={(v) => update({ collectRecipients: v })}
|
||||
label={t("Save people you write to as contacts")}
|
||||
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Working hours")}</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
|
||||
Reference in new issue
Block a user